feat: port Backup System and Daemon modules to AIPass repo (FPLAN-0004, FPLAN-0005)

Backup System (FPLAN-0004): 42 files ported from internal backup_system
- Entry point, 4 modules, 20+ handlers for backup orchestration
- Google Drive sync made optional via try/except + DRIVE_AVAILABLE flag
- All internal imports (prax, cli, sys.path) replaced with standard lib

Daemon (FPLAN-0005): 41 files ported from internal assistant system
- Renamed assistant → daemon (Patrick approved)
- Entry point, 4 modules, 10 handlers, 5 example plugins
- Telegram, ai_mail, filelock made optional with graceful degradation
- Standalone cron scripts use absolute package imports
- Paths configurable via env vars (AIPASS_DAEMON_CONFIG, AIPASS_REGISTRY, AIPASS_WAKE_SCRIPT)

Both follow the same port-by-rewiring pattern as Memory Bank (FPLAN-0003).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-03-07 00:36:02 -08:00
co-authored by Claude Opus 4.6
parent da4fa82771
commit b7f6c16793
82 changed files with 14244 additions and 0 deletions
View File
+1
View File
@@ -0,0 +1 @@
# Apps package - Branch application modules and handlers
+248
View File
@@ -0,0 +1,248 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: backup.py - Backup System Entry Point
# Date: 2025-11-22
# Version: 1.3.0
# Category: core/backup
#
# CHANGELOG (Max 5 entries):
# - v1.3.0 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang, sys.path manipulation, prax/cli imports
# * Replaced get_modules() with explicit relative imports
# * Uses standard logging and rich console
# - v1.2.0 (2026-02-22): Seed CLI flags standard: --version, backup all command
# - v1.1.0 (2025-11-22): Fixed META header - correct filename, category, and standards
# - v1.0.0 (2025-11-08): Initial version - modular architecture
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# =============================================
"""
Backup System - Main Entry Point
Modular architecture with explicit module imports.
Main handles routing, modules implement functionality.
"""
# Standard library imports
import sys
import argparse
import logging
from pathlib import Path
from typing import Any, List
from rich.console import Console
logger = logging.getLogger(__name__)
console = Console()
# Explicit module imports (replaces dynamic discover_modules)
from .modules import backup_core, google_drive_sync, integrations, reauth_drive
def get_modules():
"""Return list of modules with handle_command() interface"""
modules = []
for mod in [backup_core, google_drive_sync, integrations, reauth_drive]:
if hasattr(mod, 'handle_command'):
modules.append(mod)
return modules
# =============================================================================
# CONSTANTS & CONFIG
# =============================================================================
# Module root (same directory as this file)
MODULE_ROOT = Path(__file__).parent
# =============================================================================
# COMMAND ROUTING
# =============================================================================
def route_command(args: argparse.Namespace, modules: List[Any]) -> bool:
"""
Route command to appropriate module
Args:
args: Parsed command line arguments
modules: List of discovered modules
Returns:
True if command was handled
"""
for module in modules:
try:
if module.handle_command(args):
return True
except Exception as e:
logger.error(f"Module error: {e}")
return False
# =============================================================================
# INTROSPECTION DISPLAY
# =============================================================================
def print_introspection():
"""Display discovered modules - SEED pattern"""
console.print()
console.print("[bold cyan]Backup System - Automated File Protection[/bold cyan]")
console.print()
console.print("[dim]AIPass backup orchestration and versioning[/dim]")
console.print()
# Discover modules
modules = get_modules()
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
console.print()
for module in modules:
module_name = module.__name__ if hasattr(module, '__name__') else str(module)
# Extract just the module filename (last part)
if '.' in module_name:
module_name = module_name.split('.')[-1]
console.print(f" [cyan]•[/cyan] {module_name}")
console.print()
console.print("[dim]Run 'backup --help' for usage information[/dim]")
console.print()
# =============================================================================
# MAIN
# =============================================================================
def show_version():
"""Print version from META DATA HEADER."""
console.print("BACKUP_SYSTEM v1.2.0")
def main():
"""Main entry point - follows Seed CLI flags standard."""
# 1. No args → introspection
if len(sys.argv) == 1:
print_introspection()
return 0
# 2. Universal flags (checked before command routing, execute and exit)
if sys.argv[1] in ['--help', '-h', 'help']:
console.print()
console.print("[bold cyan]BACKUP_SYSTEM[/bold cyan] — Automated File Protection")
console.print()
console.print("[yellow]Commands:[/yellow]")
console.print(" --all Run snapshot + versioned + drive-sync (full backup cycle)")
console.print(" snapshot Create a system snapshot backup")
console.print(" versioned Create a versioned backup")
console.print(" drive-test Test Google Drive connectivity")
console.print(" drive-sync Sync backups to Google Drive")
console.print(" drive-sync --test Run a small test sync to verify integration")
console.print(" drive-stats Show Drive file tracker statistics")
console.print(" drive-clear-tracker Clear Drive file tracker cache")
console.print()
console.print("[yellow]Options:[/yellow]")
console.print(" --verbose, -v Extra diagnostic output")
console.print(" --dry-run Preview what would happen, execute nothing")
console.print(" --note NOTE Add a backup note/description")
console.print(" --project NAME Project name for Drive sync (default: AIPass)")
console.print(" --force Force sync all files (ignore change tracker)")
console.print(" --limit N Limit drive-sync to first N files")
console.print(" --version, -V Show version and exit")
console.print()
return 0
if sys.argv[1] in ['--version', '-V']:
show_version()
return 0
# Normalize --all flag → 'all' command (so both syntaxes work)
if sys.argv[1] == '--all':
sys.argv[1] = 'all'
# 3. Parse args (behavioral flags handled by argparse)
parser = argparse.ArgumentParser(
description='backup_system Branch Operations',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('command', nargs='?', help='Command to execute')
parser.add_argument('path', nargs='?', default=None, help='Path argument (for sync commands)')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
parser.add_argument('--note', type=str, default='No note provided', help='Backup note/description')
parser.add_argument('--dry-run', action='store_true', help='Scan files without copying (test mode)')
parser.add_argument('--project', type=str, default='AIPass', help='Project name for Drive sync')
parser.add_argument('--force', action='store_true', help='Force sync all files')
parser.add_argument('--test', action='store_true', help='Run test mode (small test sync)')
parser.add_argument('--limit', type=int, default=0, help='Limit sync to first N files (0 = no limit)')
args = parser.parse_args()
if not args.command:
print_introspection()
return 0
# 4. Discover modules
modules = get_modules()
if not modules:
console.print("❌ ERROR: No modules found")
return 1
# 5. Handle 'all' command: snapshot → versioned → drive-sync
if args.command == 'all':
console.print("[bold cyan]Full Backup Cycle: snapshot → versioned → drive-sync[/bold cyan]")
console.print()
# Step 1: Snapshot
snapshot_args = argparse.Namespace(
command='snapshot', path=None, verbose=args.verbose,
note=args.note, dry_run=args.dry_run, project=args.project,
force=args.force, test=False, limit=0
)
if not route_command(snapshot_args, modules):
console.print("[red]Snapshot failed - aborting[/red]")
return 1
console.print()
console.print("[dim]─── snapshot complete, starting versioned ───[/dim]")
console.print()
# Step 2: Versioned
versioned_args = argparse.Namespace(
command='versioned', path=None, verbose=args.verbose,
note=args.note, dry_run=args.dry_run, project=args.project,
force=args.force, test=False, limit=0
)
if not route_command(versioned_args, modules):
console.print("[red]Versioned backup failed - aborting[/red]")
return 1
console.print()
console.print("[dim]─── versioned complete, starting drive-sync ───[/dim]")
console.print()
# Step 3: Drive sync
sync_args = argparse.Namespace(
command='drive-sync', path=None, verbose=args.verbose,
note=args.note, dry_run=args.dry_run, project=args.project,
force=args.force, test=False, limit=args.limit
)
if not route_command(sync_args, modules):
console.print("[red]Drive sync failed[/red]")
return 1
console.print()
console.print("[green]Full backup cycle complete[/green]")
return 0
# 6. Route command to modules
if route_command(args, modules):
return 0
else:
console.print(f"❌ ERROR: Unknown command: {args.command}")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
+1
View File
@@ -0,0 +1 @@
"""Backup system handlers package."""
View File
+501
View File
@@ -0,0 +1,501 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: config_handler.py - Backup system configuration and patterns
# Date: 2025-11-23
# Version: 2.0.2
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.0.2 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang and sys.path manipulation
# * Cleared CLI_TRACKING_PATTERNS (hardcoded internal paths removed)
# - v2.0.1 (2025-11-23): Added ignore patterns for log/data JSON files
# * Enabled *_data.json pattern to ignore backup_core_data.json
# * Enabled *_log.json pattern to ignore backup_core_log.json, drone_log.json
# * Enabled snapshot_backup.json pattern
# * Prevents frequent-change files from being backed up every run
# - v2.0.0 (2025-11-16): Seed standard extraction from archive
# * All configuration constants and patterns
# * Helper functions for pattern matching and filtering
# * Pure configuration - no complex logic
# - v1.0.0 (2025-10-14): Initial extraction from backup.py
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Backup System Configuration Handler
Centralized configuration management for backup system.
Contains all backup modes, ignore patterns, and configuration constants.
Pure configuration with helper functions for pattern access and filtering.
"""
# =============================================
# IMPORTS
# =============================================
from pathlib import Path
from typing import Dict, Set, List, Optional
# =============================================
# CONFIGURATION CONSTANTS
# =============================================
# Base backup directory - dynamically determined relative to branch root
# Module is in apps/handlers/config/, so parent.parent.parent.parent gets to branch root
BASE_BACKUP_DIR = str(Path(__file__).parent.parent.parent.parent / "backups")
# Specific backup destinations for each system
BACKUP_DESTINATIONS = {
"system_snapshot": f"{BASE_BACKUP_DIR}",
"versioned_backup": f"{BASE_BACKUP_DIR}",
}
# =============================================
# IGNORE PATTERNS
# =============================================
# Global ignore patterns (what NOT to backup across all systems)
GLOBAL_IGNORE_PATTERNS = [
# Python cache and temp files
"__pycache__",
"*.pyc",
"*.pyo",
# Virtual environments
".venv",
"venv",
# Node.js
"node_modules",
"npm-debug.log",
"yarn-error.log",
# Operating system files
".DS_Store",
"Thumbs.db",
"desktop.ini",
"nul", # Windows reserved filename issue
# Temporary files
#"temp",
".temp",
"tmp",
".tmp",
"*.tmp",
"*.temp",
# Build and distribution directories
"build",
"dist",
"install",
"lib",
"bin",
# IDE and editor files
"*.swp",
"*.swo",
"*~",
# Backup directories (prevent recursive backups) - CRITICAL!
"backups", # Matches any directory named "backups"
"backup_system/backups", # Explicit path to our backup directory
"*/backups", # Any backups subdirectory
"system_snapshot", # Snapshot backup folder name
"versioned_backup", # Versioned backup folder name
"deleted_branches", # Deleted/archived branches (trash folder)
# System backups and trash
"TimeShift*",
"timeshift*",
".local/share/Trash",
"Trash",
# Linux system directories (don't backup user settings/cache)
".local",
".cache",
".config",
".mozilla",
".gnupg",
".ssh",
".vscode/cli", # VS Code CLI binary (large, regenerates)
".vscode/extensions", # VS Code extensions (large, regenerates)
".vscode-server", # VS Code remote server
".npm-global",
".pki",
".dotnet",
".var", # System application data (Flatpak, etc)
".backup", # Old backup directories
".antigravity", # AI assistant cache/data
".gemini", # AI assistant cache
# Version control (huge number of files!)
".git", # Git repositories are version controlled elsewhere
# IDE and editor directories
".idea", # IntelliJ IDEA settings
".eclipse", # Eclipse settings
# Claude Code session files (change every session) - synced from .gitignore
#".claude/projects",
".claude/todos",
".claude/shell-snapshots",
".claude/ide",
".claude/statsig",
#".claude/plugins",
".claude/.credentials.json",
".claude/debug",
".claude/file-history",
".claude/history.jsonl",
".claude/.update.lock",
".claude/.projects",
#".claude/hooks/.last_diagnostics_file",
".serena/logs",
".code",
# Development cache/build directories
".npm",
".cargo",
".rustup",
".gem",
".gradle",
".m2",
# Application data we don't need in backups
".thunderbird",
".wine",
".steam",
".zoom",
# User directories that shouldn't be backed up
"Downloads",
#"Music",
"Videos",
"Pictures",
"Dropbox",
"system_logs",
"external_repos", # External git repos - version controlled elsewhere
"mcp_servers/context7", # External MCP server repo
"mcp_servers/playwright-mcp", # External MCP server repo
"mcp_servers/serena", # External MCP server repo
"mcp_servers/servers", # External MCP server repo
"mcp_servers/dropbox", # External MCP server repo
# Archive and compressed files
"*.zip",
"*.tar",
"*.gz",
"*.bz2",
"*.rar",
"*.7z",
"*.whl", # Python wheel files (long filenames in archives)
# Large binary/image files (VMs, disk images)
"*.img", # Disk images (sandbox.img, etc - can be 50GB+)
"*.iso", # ISO files
"*.vmdk", # Virtual machine disk
"*.vdi", # VirtualBox disk image
"*.qcow2", # QEMU disk image
# Image/media files (screenshots, icons, etc.)
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.svg",
"*.webp",
"*.ico",
"*.bmp",
"*.tiff",
"*.tif",
# Log files
"*.log",
"logs",
# Most JSON files (frequent changes, not human-readable diffs)
"*_data.json",
"*_log.json",
"*_registry.json",
"snapshot_backup.json",
"snapshot_backup_changelog.json",
# Miscellaneous
"*.db",
"*.bashrc",
"*.bash_history",
"*.bash_logout",
#"*.claude.json.backup",
#"*.env",
"*.lesshst",
"*.npmrc",
"*.sudo_as_admin_successful",
]
# Notable patterns to highlight when skipped
CLI_TRACKING_PATTERNS = []
# Files that should be backed up but NOT generate diff files
DIFF_IGNORE_PATTERNS = [
# Log files (append-only, huge diffs)
"*.log",
"*.logs",
"system_logs/*.log",
# Most JSON files (frequent changes, not human-readable diffs)
#"*_config.json",
#"*_data.json",
#"*_log.json",
#"*_registry.json",
# Python cache/compiled
"*.pyc",
"*.pyo",
"*.pyd",
"__pycache__/*",
# Database files
"*.db",
"*.sqlite",
"*.sqlite3",
# Binary/media files
"*.exe",
"*.dll",
"*.so",
"*.dylib",
# Pickle/cache files
"*.pkl",
"*.pickle",
# Temporary files
"*.tmp",
"*.temp",
"*.bak",
"~*",
]
# Files that should be backed up despite matching ignore patterns
# Synced from .gitignore exceptions (! prefix patterns)
IGNORE_EXCEPTIONS = [
".gitignore", # Root .gitignore file should be backed up
"*.local.md", # AIPass session tracking files (caught by .local pattern but should be backed up)
".vscode/settings.json", # VS Code settings
"*/.claude/settings.local.json", # Claude local settings in any directory
"tools/cleanup_configs/*.json", # Cleanup config files
"*_config.json", # All config JSON files
"drone/commands/global/*.json", # Drone global commands
".claude.json", # Claude config
".mcp.json", # MCP config
".config/nerd-dictation/*", # Nerd dictation config
".commands.json", # Commands config
"BRANCH_REGISTRY.json", # Core ecosystem registry - vital file
# === TEMPLATES: FULL EXCEPTION - EVERY FILE ===
"templates/**", # FULL EXCEPTION: Include EVERY file in templates directory
"*/templates/**", # Templates in any subdirectory
"templates/*/**", # All subdirectories and files in templates
"*/templates/*/**", # All subdirectories and files in templates anywhere
# === TEMPLATE SUBDIRECTORIES (explicit) ===
"templates/ai_branch_setup_template/ai_mail.local/**",
"templates/ai_branch_setup_template/logs/**",
"templates/ai_branch_setup_template/standards.local/**",
"templates/ai_branch_setup_template/.claude/**",
"templates/ai_branch_setup_template/.archive/**",
# === MARKERS ===
".gitkeep", # Include all .gitkeep marker files (especially in templates)
".gitattributes", # Git attributes files
".local.json"
]
# Files that SHOULD have diffs created (exceptions to ignore patterns)
DIFF_INCLUDE_PATTERNS = [
"profile.json",
"pyrightconfig.json",
"package.json",
".mcp.json",
"settings.json",
"settings.local.json",
]
# =============================================
# BACKUP MODE CONFIGURATIONS
# =============================================
BACKUP_MODES = {
'snapshot': {
'name': 'System Snapshot',
'description': 'Dynamic instant backup (overwrites previous)',
'destination': BACKUP_DESTINATIONS["system_snapshot"],
'folder_name': 'system_snapshot',
'behavior': 'dynamic', # overwrites previous
'usage': 'Quick saves before changes'
},
'versioned': {
'name': 'Versioned Backup',
'description': 'Cumulative version history (keeps all file versions)',
'destination': BACKUP_DESTINATIONS["versioned_backup"],
'folder_name': 'versioned_backup',
'behavior': 'versioned', # keeps all versions
'usage': 'Complete file version history in single location'
},
}
# =============================================
# HELPER FUNCTIONS
# =============================================
def get_ignore_patterns() -> List[str]:
"""Get the global ignore patterns list
Returns:
Copy of global ignore patterns list
"""
return GLOBAL_IGNORE_PATTERNS.copy()
def get_cli_tracking_patterns() -> List[str]:
"""Get the CLI tracking patterns list
Returns:
Copy of CLI tracking patterns list
"""
return CLI_TRACKING_PATTERNS.copy()
def get_backup_destination(system_name: str) -> str:
"""Get backup destination for a specific backup system
Args:
system_name: Name of the backup system
Returns:
Path to backup destination, or base directory if not found
"""
return BACKUP_DESTINATIONS.get(system_name, BASE_BACKUP_DIR)
def filter_tracked_items(skipped_items: Dict[str, Set[str]]) -> Dict[str, Set[str]]:
"""Filter skipped items to only show project-specific items
Uses CLI tracking patterns to identify important items worth showing to user.
Args:
skipped_items: Dictionary with 'directories' and 'files' sets
Returns:
Filtered dictionary with only tracked items
"""
tracking_patterns = get_cli_tracking_patterns()
filtered_items = {
"directories": set(),
"files": set()
}
def matches_pattern(item_path: str, patterns: List[str]) -> bool:
"""Check if item path matches any tracking pattern"""
for pattern in patterns:
if pattern in item_path or item_path.startswith(pattern):
return True
# Check wildcard patterns
if pattern.startswith('*') and item_path.endswith(pattern[1:]):
return True
return False
# Filter directories
for directory in skipped_items.get("directories", set()):
if matches_pattern(directory, tracking_patterns):
filtered_items["directories"].add(directory)
# Filter files
for file_path in skipped_items.get("files", set()):
if matches_pattern(file_path, tracking_patterns):
filtered_items["files"].add(file_path)
return filtered_items
def should_ignore(path: Path, ignore_patterns: Optional[List[str]] = None,
exceptions: Optional[List[str]] = None,
backup_dest: Optional[Path] = None) -> bool:
"""Check if a file/folder should be ignored based on patterns.
Centralizes ignore pattern matching logic used during backup scanning.
Checks exceptions first (files that should NOT be ignored), then patterns.
Args:
path: Path object to check
ignore_patterns: List of patterns to ignore (defaults to GLOBAL_IGNORE_PATTERNS)
exceptions: List of exception patterns (defaults to IGNORE_EXCEPTIONS)
backup_dest: Optional backup destination to always ignore
Returns:
True if path should be ignored, False otherwise
Example:
# From backup engine
should_ignore(Path("/home/user/file.pyc")) # True
should_ignore(Path("/home/user/.gitignore")) # False (exception)
"""
import os
# Use defaults if not provided
if ignore_patterns is None:
ignore_patterns = GLOBAL_IGNORE_PATTERNS
if exceptions is None:
exceptions = IGNORE_EXCEPTIONS
path_str = str(path)
parts = set(path_str.split(os.sep))
name = path.name
# Always ignore backup destination if provided
if backup_dest and str(backup_dest) in path_str:
return True
# Ignore paths containing 'Backups'
if 'Backups' in parts:
return True
# Check exceptions first - files that should NOT be ignored
for exception in exceptions:
# Full path matching for template exceptions
if "**" in exception:
# Convert glob pattern to regex-like check
exception_parts = exception.split("/**")[0] # Get everything before /**
if exception_parts in path_str or exception_parts in "/".join(parts):
return False # Matches exception pattern - don't ignore
elif exception.startswith('*') and name.endswith(exception[1:]):
return False # Matches wildcard exception pattern
elif exception == name:
return False # Exact match
elif exception in path_str:
return False # Exception pattern is in the full path
# Check ignore patterns
for pattern in ignore_patterns:
# Special case: "backups" should only match directory names, not filenames
if pattern == "backups":
if "backups" in parts: # Only ignore if "backups" is a directory in the path
return True
elif pattern == name:
return True
elif pattern.startswith('*') and name.endswith(pattern[1:]):
return True
elif pattern in parts or pattern in path_str:
return True
return False
# =============================================
# MODULE INITIALIZATION
# =============================================
# No initialization needed - pure configuration
View File
+120
View File
@@ -0,0 +1,120 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: diff_generator.py - Unified diff generation with binary detection
# Date: 2025-11-16
# Version: 2.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2025-11-16): Extracted from backup_diff.py
# * Diff generation functionality
# * Binary file detection
# * Unified diff content generation
# * Updated imports to new handler locations
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Diff Generation Handler
Generates unified diffs between file versions with binary file detection.
Supports pattern-based filtering for diff creation.
"""
# =============================================
# IMPORTS
# =============================================
import datetime
import difflib
from pathlib import Path
# Import from handlers
from ..utils.system_utils import safe_print
from ..config.config_handler import DIFF_IGNORE_PATTERNS, DIFF_INCLUDE_PATTERNS
# =============================================
# DIFF GENERATION
# =============================================
def should_create_diff(file_path: Path) -> bool:
"""Check if file should have diffs created based on ignore patterns.
Args:
file_path: Path to file to check
Returns:
True if diff should be created, False otherwise
"""
# Check include patterns first (exceptions that should always have diffs)
for pattern in DIFF_INCLUDE_PATTERNS:
if file_path.match(pattern):
return True
# Then check ignore patterns
for pattern in DIFF_IGNORE_PATTERNS:
if file_path.match(pattern):
return False
# Default: create diff for all other files
return True
def is_binary_file(file_path: Path) -> bool:
"""Check if a file is likely binary.
Args:
file_path: Path to file to check
Returns:
True if file appears to be binary, False otherwise
"""
try:
with open(file_path, 'rb') as f:
chunk = f.read(1024)
return b'\0' in chunk
except Exception:
return True # Assume binary if we can't read it
def generate_diff_content(old_file: Path, new_file: Path) -> str:
"""Generate unified diff content between two files.
Args:
old_file: Path to old version of file
new_file: Path to new version of file
Returns:
Unified diff string showing changes
"""
try:
# Check if files are likely binary
if is_binary_file(old_file) or is_binary_file(new_file):
return f"Binary file {old_file.name} changed\n"
# Read file contents
with open(old_file, 'r', encoding='utf-8', errors='replace') as f:
old_lines = f.readlines()
with open(new_file, 'r', encoding='utf-8', errors='replace') as f:
new_lines = f.readlines()
# Generate unified diff
diff_lines = difflib.unified_diff(
old_lines,
new_lines,
fromfile=f"a/{old_file.name}",
tofile=f"b/{new_file.name}",
fromfiledate=datetime.datetime.fromtimestamp(old_file.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
tofiledate=datetime.datetime.fromtimestamp(new_file.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
lineterm=''
)
return '\n'.join(diff_lines)
except Exception as e:
return f"Error generating diff: {e}\n"
+151
View File
@@ -0,0 +1,151 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: version_manager.py - Version discovery and management
# Date: 2025-11-16
# Version: 2.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2025-11-16): Extracted from backup_diff.py
# * Version file discovery functionality
# * Versioned file listing
# * Support for file-organized structure
# * Updated imports to new handler locations
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Version Manager Handler
Discovers and manages versioned backup files using the file-organized structure.
Supports version listing and filtering.
"""
# =============================================
# IMPORTS
# =============================================
import logging
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# Import from handlers
from ..utils.system_utils import safe_print
# =============================================
# VERSION FILE DISCOVERY
# =============================================
def get_versioned_files(backup_path: Path, file_path: str | None = None) -> Dict[str, List[str]]:
"""Get all versioned files using new file-organized structure.
Args:
backup_path: Path to backup root directory
file_path: Optional filter to search for specific file
Returns:
Dictionary mapping file paths to lists of version timestamps
"""
versioned_files = {}
if not backup_path.exists():
return versioned_files
# Normalize search path for cross-platform compatibility
normalized_search_path = None
if file_path:
normalized_search_path = str(Path(file_path)).replace('\\', '/').lower()
# NEW STRUCTURE: Look for *_diffs folders
for diff_folder in backup_path.rglob('*_diffs'):
if not diff_folder.is_dir():
continue
# Extract base filename from diff folder name (remove _diffs suffix)
if not diff_folder.name.endswith('_diffs'):
continue
base_filename = diff_folder.name[:-6] # Remove '_diffs'
# Get relative path from backup root to the file
# diff_folder structure: .../filename/filename_diffs/
file_folder = diff_folder.parent
if file_folder.name != base_filename:
continue # Skip if structure doesn't match expected pattern
# Calculate base path for this file
rel_path_to_file_folder = file_folder.relative_to(backup_path)
base_path = str(rel_path_to_file_folder / base_filename)
# Filter by search path if provided
if file_path:
normalized_base_path = str(base_path).replace('\\', '/').lower()
if normalized_search_path is not None and normalized_search_path not in normalized_base_path:
continue
# Find all diff files in this folder
versions = []
for diff_file in diff_folder.glob('*_v*.diff'):
filename = diff_file.name
if '_v' in filename and filename.endswith('.diff'):
# Extract version from filename: basename_v<timestamp>.diff
version_part = filename.split('_v')[1].replace('.diff', '')
versions.append(version_part)
if versions:
versioned_files[base_path] = sorted(versions, reverse=True) # Newest first
return versioned_files
def list_versioned_files(backup_path: Path) -> bool:
"""List all versioned files with their version counts.
Args:
backup_path: Path to backup root directory
Returns:
True if files were found and listed, False otherwise
"""
try:
versioned_files = get_versioned_files(backup_path)
if not versioned_files:
safe_print("No versioned files found")
return False
safe_print(f"\n{'='*70}")
safe_print("VERSIONED FILES HISTORY")
safe_print('='*70)
for file_path, versions in versioned_files.items():
version_count = len(versions)
latest_version = versions[0] if versions else "unknown"
safe_print(f" {file_path}")
safe_print(f" Versions: {version_count} | Latest: {latest_version}")
# Show first 3 versions
for i, version in enumerate(versions[:3]):
marker = ">" if i == 0 else " "
safe_print(f" {marker} v{version}")
if len(versions) > 3:
safe_print(f" ... and {len(versions) - 3} more versions")
safe_print("")
safe_print('='*70)
safe_print(f"Use: python backup.py diff --file <path> to see changes")
return True
except Exception as e:
safe_print(f"Error listing versioned files: {e}")
logger.error(f"[version_manager] Failed to list versioned files: {e}")
return False
+234
View File
@@ -0,0 +1,234 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: vscode_integration.py - VS Code diff viewer integration
# Date: 2025-11-16
# Version: 2.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2025-11-16): Extracted from backup_diff.py
# * VS Code diff viewer integration
# * File comparison logic
# * Baseline vs current comparison
# * Updated imports to new handler locations
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
VS Code Integration Handler
Opens file diffs in VS Code editor for visual comparison.
Supports baseline vs current and version-to-version comparisons.
"""
# =============================================
# IMPORTS
# =============================================
import sys
import os
import subprocess
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# Import from handlers
from ..utils.system_utils import safe_print
from ..diff.version_manager import get_versioned_files
# =============================================
# VS CODE INTEGRATION
# =============================================
def show_file_diff(backup_path: Path, source_dir: Path, file_path: str, version1: str | None = None, version2: str | None = None) -> bool:
"""Show diff between two versions of a file in VS Code.
Args:
backup_path: Path to backup root directory
source_dir: Path to source directory (for current file comparison)
file_path: File path to show diff for
version1: First version timestamp (optional)
version2: Second version timestamp (optional)
Returns:
True if diff was opened successfully, False otherwise
"""
try:
versioned_files = get_versioned_files(backup_path, file_path)
if not versioned_files:
safe_print(f"No versioned files found for: {file_path}")
return False
# Find the matching file with normalized path comparison
matching_file = None
normalized_search_path = str(Path(file_path)).replace('\\', '/').lower()
for base_path in versioned_files:
normalized_base_path = str(base_path).replace('\\', '/').lower()
if normalized_search_path in normalized_base_path:
matching_file = base_path
break
if not matching_file:
safe_print(f"File not found: {file_path}")
return False
versions = versioned_files[matching_file]
if len(versions) < 1:
safe_print(f"Only one version found for: {file_path}")
return False
# Determine which versions to compare
if version1 and version2:
if version1 not in versions or version2 not in versions:
safe_print(f"Version not found. Available: {', '.join(versions)}")
return False
else:
# Compare latest version with current file, or two most recent versions
current_file = backup_path / matching_file
if current_file.exists() and len(versions) >= 1:
version1 = "current"
version2 = versions[0] # Most recent version
elif len(versions) >= 2:
version1 = versions[1] # Second most recent
version2 = versions[0] # Most recent
else:
safe_print(f"Need at least 2 versions to compare")
return False
# Determine file paths for comparison using NEW STRUCTURE
# For VS Code diff, we need actual files, not diff patches
try:
# NEW STRUCTURE: matching_file is like "root/CLAUDE.local.md/CLAUDE.local.md"
# We need to find the file folder and look for baseline + current file
# Extract the base filename for pattern matching
base_filename = Path(matching_file).name
file_folder_path = backup_path / Path(matching_file).parent
# Look for baseline file in the file folder
# Handle files with extensions like CLAUDE.local.md -> CLAUDE.local-baseline-*
name_without_ext = base_filename.rsplit('.', 1)[0] if '.' in base_filename else base_filename
baseline_pattern = f"{name_without_ext}-baseline-*"
baseline_files = list(file_folder_path.glob(baseline_pattern))
# Current backup file path (in the organized structure)
current_backup_file = file_folder_path / base_filename
# Source file path (live file in source directory)
# Convert backup path back to source path
matching_file_normalized = str(Path(matching_file)).replace('\\', '/')
if matching_file_normalized.startswith("root/"):
# Root level file: root/filename/filename -> filename
source_relative_path = base_filename
else:
# Nested file: folder/filename/filename -> folder/filename
source_relative_path = str(Path(matching_file).parent.parent / base_filename)
source_file_path = source_dir / source_relative_path
if baseline_files:
# ALWAYS prefer baseline comparison when available
file1_path = baseline_files[0] # Use first baseline found
file2_path = source_file_path # Current source
label1 = f"{base_filename} (baseline)"
label2 = f"{base_filename} (current)"
elif current_backup_file.exists():
# No baseline, compare backup against current source
file1_path = current_backup_file # Backup version
file2_path = source_file_path # Live source
label1 = f"{base_filename} (backup)"
label2 = f"{base_filename} (current)"
# Check if they're the same
if file1_path.exists() and file2_path.exists():
if file1_path.stat().st_mtime == file2_path.stat().st_mtime:
safe_print(f"Note: Backup is identical to current file (no changes)")
safe_print(f" This happens when Google Drive sync updates the backup")
else:
safe_print(f"Could not find backup file in new structure: {matching_file}")
return False
# Verify files exist
if not file1_path.exists():
safe_print(f"File not found: {file1_path}")
return False
if not file2_path.exists():
safe_print(f"File not found: {file2_path}")
return False
except Exception as e:
safe_print(f"Error locating files: {e}")
logger.error(f"[vscode_integration] Error locating files for diff: {e}")
return False
# Open diff in VS Code
safe_print(f"\n{'='*70}")
safe_print(f"Opening diff in VS Code: {matching_file}")
safe_print(f"Comparing: {label2} -> {label1}")
safe_print('='*70)
# Launch VS Code with diff view
try:
# Try to find VS Code command
code_cmd = 'code'
if sys.platform == 'win32':
# On Windows, try common VS Code paths if 'code' not in PATH
import shutil
if not shutil.which('code'):
# Platform-specific VS Code detection
if sys.platform == 'win32':
# Windows VS Code path
vscode_path = Path(r"C:\Users") / os.environ.get('USERNAME', 'input') / r"AppData\Local\Programs\Microsoft VS Code\bin\code.cmd"
if vscode_path.exists():
code_cmd = str(vscode_path)
else:
code_cmd = 'code.cmd'
else:
# Linux - VS Code is usually in PATH or use snap
if Path('/snap/bin/code').exists():
code_cmd = '/snap/bin/code'
elif Path('/usr/bin/code').exists():
code_cmd = '/usr/bin/code'
else:
code_cmd = 'code'
# Use subprocess for better error handling
# Swap order so baseline (older) is on left, current (newer) is on right
result = subprocess.run(
[code_cmd, '--diff', str(file1_path), str(file2_path)],
capture_output=True,
text=True,
shell=True if sys.platform == 'win32' else False
)
if result.returncode == 0:
safe_print(f"Diff opened in VS Code")
else:
safe_print(f"VS Code returned code {result.returncode}")
if result.stderr:
safe_print(f"Error: {result.stderr}")
except FileNotFoundError:
safe_print(f"VS Code command not found. Make sure 'code' is in your PATH")
safe_print(f"You can manually compare:")
safe_print(f" File 1: {file2_path}")
safe_print(f" File 2: {file1_path}")
return False
except Exception as e:
safe_print(f"Error launching VS Code: {e}")
logger.error(f"[vscode_integration] Error launching VS Code: {e}")
return False
safe_print('='*70)
return True
except Exception as e:
safe_print(f"Error generating diff: {e}")
logger.error(f"[vscode_integration] Error in show_file_diff: {e}")
return False
+12
View File
@@ -0,0 +1,12 @@
"""JSON Handlers - Universal JSON operations for Seed branch"""
from .changelog_handler import (
load_changelog,
save_changelog_entry,
display_previous_comments
)
from .backup_info_handler import (
load_backup_info,
save_backup_info
)
@@ -0,0 +1,90 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: backup_info_handler.py - Backup state and statistics
# Date: 2025-11-16
# Version: 1.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-16): Initial extraction from backup_core.py
# * load_backup_info() - Load mode-specific state
# * save_backup_info() - Persist backup state
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Backup Info Handler
Manages backup state and statistics persistence.
Handles mode-specific formats (dynamic vs versioned).
Functions:
load_backup_info: Load mode-specific backup state
save_backup_info: Persist backup state to JSON
"""
# =============================================
# IMPORTS
# =============================================
import json
import datetime
from pathlib import Path
from typing import Dict
# =============================================
# BACKUP INFO OPERATIONS
# =============================================
def load_backup_info(backup_info_file: Path, mode_behavior: str) -> Dict:
"""Load backup information from JSON file.
Args:
backup_info_file: Path to backup_info JSON file
mode_behavior: 'dynamic' or 'versioned'
Returns:
Dictionary with backup information (mode-specific format)
"""
if backup_info_file.exists():
try:
with open(backup_info_file, 'r', encoding='utf-8', errors='replace') as f:
return json.load(f)
except Exception:
pass
# Return mode-specific default structure
if mode_behavior == 'versioned':
return {"backups": []}
else: # dynamic
return {"last_backup": None, "file_timestamps": {}}
def save_backup_info(backup_info_file: Path, backup_info: Dict) -> bool:
"""Save backup information to JSON file.
Args:
backup_info_file: Path to backup_info JSON file
backup_info: Dictionary containing backup information
Returns:
True if save succeeded, False otherwise
"""
try:
with open(backup_info_file, 'w', encoding='utf-8') as f:
json.dump(backup_info, f, indent=2, ensure_ascii=False)
return True
except Exception:
return False
# =============================================
# MODULE INITIALIZATION
# =============================================
# No initialization needed - pure utility functions
@@ -0,0 +1,100 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: backup_metadata_builder.py - Backup metadata construction
# Date: 2025-11-18
# Version: 1.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted backup_info creation logic
# * Handles mode-specific metadata formatting
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Backup Metadata Builder - Constructs backup metadata structures
Creates mode-specific backup information structures.
"""
# =============================================
# IMPORTS
# =============================================
import datetime
from pathlib import Path
from ..models.backup_models import BackupResult
# =============================================
# METADATA CONSTRUCTION OPERATIONS
# =============================================
def create_backup_metadata(mode: str, behavior: str, backup_note: str, backup_folder_name: str,
backup_path: Path, source_dir: Path, result: BackupResult,
current_timestamps: dict, existing_backup_info: dict) -> dict:
"""Create backup metadata structure based on mode.
Args:
mode: Backup mode ('snapshot' or 'versioned')
behavior: Mode behavior ('dynamic' or 'versioned')
backup_note: User note for this backup
backup_folder_name: Backup folder name
backup_path: Backup destination path
source_dir: Source directory root
result: BackupResult with operation statistics
current_timestamps: Dict of current file timestamps
existing_backup_info: Existing backup info to append to
Returns:
Complete backup info dict ready to save
"""
if behavior == 'versioned':
# Versioned: add to backup list
current_backup = {
"backup_note": backup_note,
"backup_name": backup_folder_name,
"timestamp": datetime.datetime.now().isoformat(),
"backup_path": str(backup_path),
"source_path": str(source_dir),
"mode": mode,
"stats": {
"files_checked": result.files_checked,
"files_copied": result.files_copied,
"files_added": result.files_added,
"files_skipped": result.files_skipped,
"errors": result.errors
}
}
existing_backup_info["backups"].insert(0, current_backup)
return existing_backup_info
else:
# Dynamic: update current state
return {
"backup_note": backup_note,
"last_backup": datetime.datetime.now().isoformat(),
"file_timestamps": current_timestamps,
"mode": mode,
"backup_path": str(backup_path),
"stats": {
"files_checked": result.files_checked,
"files_copied": result.files_copied,
"files_added": result.files_added,
"files_skipped": result.files_skipped,
"files_deleted": result.files_deleted,
"errors": result.errors
}
}
# =============================================
# MODULE INITIALIZATION
# =============================================
# Pure handler - no initialization needed
@@ -0,0 +1,157 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: changelog_handler.py - Backup changelog management
# Date: 2025-11-16
# Version: 1.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-16): Initial extraction from backup_core.py
# * load_changelog() - Load persistent changelog
# * save_changelog_entry() - Add new entry
# * display_previous_comments() - Show history
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Backup Changelog Handler
Manages persistent changelog of backup operations with notes/comments.
Tracks all backup operations with timestamps and user notes for history tracking.
Functions:
load_changelog: Load changelog from JSON file
save_changelog_entry: Add timestamped entry to changelog
display_previous_comments: Display recent entries to user
"""
# =============================================
# IMPORTS
# =============================================
import json
import logging
import datetime
from pathlib import Path
from typing import Dict
logger = logging.getLogger(__name__)
from rich.console import Console
# Initialize console for output
console = Console()
# =============================================
# CHANGELOG OPERATIONS
# =============================================
def load_changelog(changelog_file: Path) -> Dict:
"""Load persistent changelog of backup comments.
Args:
changelog_file: Path to changelog JSON file
Returns:
Dictionary with 'entries' list (empty dict if file missing)
"""
if changelog_file.exists():
try:
with open(changelog_file, 'r', encoding='utf-8', errors='replace') as f:
return json.load(f)
except Exception as e:
console.print(f"[red]Error loading changelog: {e}[/red]")
logger.warning(f"Error loading changelog: {e}")
return {"entries": []}
def save_changelog_entry(changelog_file: Path, note: str, mode: str,
backup_path: Path) -> bool:
"""Add new entry to persistent changelog.
Args:
changelog_file: Path to changelog JSON file
note: User note describing the backup
mode: Backup mode ('snapshot' or 'versioned')
backup_path: Path to backup destination
Returns:
True if save succeeded, False otherwise
"""
try:
changelog = load_changelog(changelog_file)
new_entry = {
"timestamp": datetime.datetime.now().isoformat(),
"note": note,
"mode": mode,
"backup_path": str(backup_path)
}
changelog["entries"].append(new_entry)
with open(changelog_file, 'w', encoding='utf-8') as f:
json.dump(changelog, f, indent=2, ensure_ascii=False)
logger.info(f"[changelog_handler] Saved changelog entry: {note[:50]}")
return True
except Exception as e:
console.print(f"[red]Error saving changelog entry: {e}[/red]")
logger.error(f"[changelog_handler] Error saving changelog: {e}")
return False
def display_previous_comments(changelog_file: Path, mode_name: str):
"""Display previous backup comments with mode identification.
Args:
changelog_file: Path to changelog JSON file
mode_name: Display name of backup mode ('System Snapshot', etc.)
"""
try:
changelog = load_changelog(changelog_file)
entries = changelog.get("entries", [])
if not entries:
console.print(f"[yellow]No previous {mode_name} backup comments found.[/yellow]")
return
from rich.panel import Panel
console.print()
console.print(Panel(f"PREVIOUS {mode_name.upper()} BACKUP COMMENTS",
style="bold cyan",
border_style="cyan"))
# Show last 10 entries (most recent first)
recent_entries = entries[-10:]
for i, entry in enumerate(reversed(recent_entries), 1):
try:
timestamp = datetime.datetime.fromisoformat(entry["timestamp"])
formatted_time = timestamp.strftime("%Y-%m-%d %H:%M")
mode_info = entry.get('mode', 'unknown')
# Handle encoding issues in notes
note = str(entry['note']).encode('ascii', errors='replace').decode('ascii')
console.print(f"[bright_blue]{i:2d}.[/bright_blue] [{formatted_time}] [green][{mode_info}][/green] {note}")
except Exception as e:
console.print(f"[bright_blue]{i:2d}.[/bright_blue] [red][ERROR] Failed to display entry: {e}[/red]")
if len(entries) > 10:
console.print(f"\n[dim]... and {len(entries) - 10} older entries[/dim]")
console.print()
except FileNotFoundError:
console.print(f"[yellow]No previous {mode_name} backup comments found.[/yellow]")
except PermissionError as e:
console.print(f"[yellow]Warning: Cannot read backup history - permission denied: {e}[/yellow]")
console.print("[yellow]Continuing with backup...[/yellow]")
except Exception as e:
console.print(f"[yellow]Warning: Error displaying comments: {e}[/yellow]")
console.print("[yellow]Continuing with backup...[/yellow]")
# =============================================
# MODULE INITIALIZATION
# =============================================
# No module-level initialization needed
@@ -0,0 +1,236 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: drive_sync_json.py - Google Drive Sync JSON Operations Handler
# Date: 2026-02-20
# Version: 1.0.0
# Category: backup_system/handlers/json
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Extracted from google_drive_sync module
# * Moved JSON I/O functions to handler layer
# * Follows seed 3-layer architecture standards
#
# CODE STANDARDS:
# - Handlers must be independent and transportable
# - No prax imports (handler tier)
# - Pure file I/O operations
# =============================================
"""
Google Drive Sync JSON Operations Handler
Handles all JSON file read/write operations for the google_drive_sync module.
Provides load/save functions for config, data, and log files.
"""
import copy
import fcntl
import json
import os
import tempfile
import time
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
# Module JSON file paths (resolved by caller)
_log_lock = threading.Lock()
def atomic_json_write(file_path: Path, data: Any) -> None:
"""Write JSON atomically via temp file + rename to prevent corruption.
Args:
file_path: Target JSON file path
data: Data to serialize as JSON
Raises:
Exception: If write fails after cleanup attempt
"""
fd, tmp_path = tempfile.mkstemp(suffix='.tmp', dir=str(file_path.parent))
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, str(file_path))
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def load_config(config_file: Path) -> Dict[str, Any]:
"""Load module configuration from JSON file.
Args:
config_file: Path to the config JSON file
Returns:
Dict with config data, or empty dict on error
"""
try:
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
except Exception:
return {}
def save_config(config_file: Path, config: Dict[str, Any]) -> None:
"""Save module configuration to JSON file.
Args:
config_file: Path to the config JSON file
config: Config data to save
Raises:
Exception: If save fails
"""
config_file.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(config_file, config)
def load_data(data_file: Path) -> Dict[str, Any]:
"""Load runtime data from JSON file.
Args:
data_file: Path to the data JSON file
Returns:
Dict with runtime data, or empty dict on error
"""
try:
if data_file.exists():
with open(data_file, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
except Exception:
return {}
def save_data(data_file: Path, data: Dict[str, Any]) -> None:
"""Save runtime data to JSON file with deepcopy protection.
Args:
data_file: Path to the data JSON file
data: Runtime data to save
Raises:
RuntimeError: If deepcopy fails after 3 retries
"""
data_file.parent.mkdir(parents=True, exist_ok=True)
snapshot: Dict[str, Any] = {}
for attempt in range(3):
try:
snapshot = copy.deepcopy(data)
break
except RuntimeError:
if attempt < 2:
time.sleep(0.05 * (attempt + 1))
else:
raise
snapshot["last_updated"] = datetime.now().isoformat()
atomic_json_write(data_file, snapshot)
def load_log(log_file: Path, max_retries: int = 3) -> Dict[str, Any]:
"""Load operation log from JSON file with cross-process locking and retry.
Args:
log_file: Path to the log JSON file
max_retries: Number of retry attempts on decode error
Returns:
Dict with log data and summary
"""
default_log: Dict[str, Any] = {
"entries": [],
"summary": {"total_entries": 0, "last_entry": None, "next_id": 1}
}
for attempt in range(max_retries):
try:
if log_file.exists() and log_file.stat().st_size > 0:
with open(log_file, 'r', encoding='utf-8') as f:
fcntl.flock(f, fcntl.LOCK_SH)
try:
return json.load(f)
finally:
fcntl.flock(f, fcntl.LOCK_UN)
else:
save_log(log_file, default_log)
return default_log
except json.JSONDecodeError:
if attempt < max_retries - 1:
time.sleep(0.1 * (attempt + 1))
else:
return default_log
except Exception:
return default_log
return default_log
def save_log(log_file: Path, log: Dict[str, Any]) -> None:
"""Save operation log to JSON file with cross-process locking.
Args:
log_file: Path to the log JSON file
log: Log data to save
Raises:
Exception: If save fails
"""
log_file.parent.mkdir(parents=True, exist_ok=True)
lock_path = log_file.with_suffix('.lock')
with open(lock_path, 'w') as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
atomic_json_write(log_file, log)
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
def log_operation(
log_file: Path,
operation: str,
details: Dict[str, Any],
success: bool = True,
correlation_id: Optional[str] = None
) -> None:
"""Log a business operation to the module's JSON log file.
Args:
log_file: Path to the log JSON file
operation: Operation name/identifier
details: Dict with 'message', optional 'execution_time_ms', 'error_details'
success: True if operation succeeded
correlation_id: Optional correlation ID for tracing
"""
with _log_lock:
log = load_log(log_file)
log_entry = {
"id": log["summary"].get("next_id", 1),
"timestamp": datetime.now().isoformat(),
"level": "INFO" if success else "ERROR",
"operation": operation,
"message": details.get("message", ""),
"success": success,
"execution_time_ms": details.get("execution_time_ms", 0),
"correlation_id": correlation_id
}
if not success:
log_entry["error_details"] = details.get("error_details", {})
log["entries"].insert(0, log_entry)
log["entries"] = log["entries"][:100]
log["summary"]["total_entries"] += 1
log["summary"]["last_entry"] = log_entry["timestamp"]
log["summary"]["next_id"] = log_entry["id"] + 1
save_log(log_file, log)
+263
View File
@@ -0,0 +1,263 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: json_handler.py - JSON Auto-Creating Handler
# Date: 2025-11-22
# Version: 3.1.0
# Category: backup_system/handlers/json
#
# CHANGELOG (Max 5 entries):
# - v3.1.0 (2026-01-31): Refactored to comply with error handling standards
# - v3.0.0 (2025-11-22): Updated to match Cortex template with all features
# - v2.0.0 (2025-11-16): Migrated to seed 3-layer architecture
# - v1.0.0 (2025-10-14): Initial implementation with auto-detection
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
import json
import inspect
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
# Constants
_BACKUP_ROOT = Path(__file__).resolve().parents[4] # src/aipass/backup/
BACKUP_JSON_DIR = _BACKUP_ROOT / "backup_json"
JSON_TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "json_templates"
def _get_caller_module_name() -> str:
"""
Auto-detect calling module name from call stack
Returns:
Module name (e.g., "backup_core" from backup_core.py)
"""
stack = inspect.stack()
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
if len(stack) > 2:
caller_frame = stack[2]
caller_path = Path(caller_frame.filename)
module_name = caller_path.stem
# Validate module name
if module_name and not module_name.startswith('_'):
return module_name
# Fallback
return "unknown"
def load_template(json_type: str, module_name: str) -> Any:
"""Load JSON template from template file"""
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}")
with open(template_path, 'r', encoding='utf-8') as f:
template = json.load(f)
# Replace placeholders
template_str = json.dumps(template)
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat())
return json.loads(template_str)
def validate_json_structure(data: Any, json_type: str) -> bool:
"""Validate JSON structure matches expected type"""
if json_type == "config":
if not isinstance(data, dict):
return False
required = ["module_name", "version", "config"]
return all(key in data for key in required)
elif json_type == "data":
if not isinstance(data, dict):
return False
required = ["created", "last_updated"]
return all(key in data for key in required)
elif json_type == "log":
return isinstance(data, list)
return False
def get_json_path(module_name: str, json_type: str) -> Path:
"""Get path for module JSON file"""
filename = f"{module_name}_{json_type}.json"
return BACKUP_JSON_DIR / filename
def ensure_json_exists(module_name: str, json_type: str) -> bool:
"""Ensure JSON file exists, create from template if missing"""
BACKUP_JSON_DIR.mkdir(parents=True, exist_ok=True)
json_path = get_json_path(module_name, json_type)
if json_path.exists():
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if validate_json_structure(data, json_type):
return True
# If corrupted, fall through to regenerate
except Exception:
# If unreadable, fall through to regenerate
pass
template = load_template(json_type, module_name)
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(template, f, indent=2, ensure_ascii=False)
return True
def load_json(module_name: str, json_type: str) -> Optional[Any]:
"""Load JSON file, auto-create if missing"""
if not ensure_json_exists(module_name, json_type):
return None
json_path = get_json_path(module_name, json_type)
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_json(module_name: str, json_type: str, data: Any) -> bool:
"""Save JSON file"""
json_path = get_json_path(module_name, json_type)
if not validate_json_structure(data, json_type):
raise ValueError(f"Invalid structure for {json_type} JSON")
if json_type == "data" and isinstance(data, dict):
data["last_updated"] = datetime.now().date().isoformat()
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
def ensure_module_jsons(module_name: str) -> bool:
"""Ensure all 3 JSON files exist for a module"""
ensure_json_exists(module_name, "config")
ensure_json_exists(module_name, "data")
ensure_json_exists(module_name, "log")
return True
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
"""
Add entry to module log with automatic rotation
Auto-detects calling module if module_name not provided.
Implements config-controlled log limits to prevent unbounded growth.
When max_log_entries is reached, removes oldest entries (FIFO).
Args:
operation: Operation name to log
data: Optional data dict
module_name: Optional module name (auto-detected if not provided)
Returns:
True if successful, False otherwise
"""
# Auto-detect module name if not provided
if module_name is None:
module_name = _get_caller_module_name()
ensure_module_jsons(module_name)
# Load config to get max_log_entries
config = load_json(module_name, "config")
max_entries = 100 # Default
if config and "config" in config:
max_entries = config["config"].get("max_log_entries", 100)
# Load existing log
log = load_json(module_name, "log")
if log is None:
log = []
# Create new entry
entry = {
"timestamp": datetime.now().isoformat(),
"operation": operation
}
if data:
entry["data"] = data # type: ignore[assignment]
# Add new entry
log.append(entry)
# Rotate if exceeds max (keep most recent entries)
if len(log) > max_entries:
log = log[-max_entries:]
return save_json(module_name, "log", log)
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
"""Increment a counter in data JSON"""
ensure_module_jsons(module_name)
data = load_json(module_name, "data")
if data is None:
return False
if counter_name not in data:
data[counter_name] = 0
data[counter_name] += amount
return save_json(module_name, "data", data)
def update_data_metrics(module_name: str, **metrics) -> bool:
"""Update data metrics"""
ensure_module_jsons(module_name)
data = load_json(module_name, "data")
if data is None:
return False
for key, value in metrics.items():
data[key] = value
return save_json(module_name, "data", data)
if __name__ == "__main__":
from rich.console import Console
from rich.panel import Panel
console = Console()
console.print()
console.print(Panel.fit(
"[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]",
border_style="bright_blue"
))
console.print()
console.print("[yellow]TESTING:[/yellow] Creating BACKUP_SYSTEM JSONs...")
# Test auto-creation
log_operation("test_operation", {"test": "data"}, "backup_system")
increment_counter("backup_system", "test_counter", 1)
update_data_metrics("backup_system", test_metric="working")
console.print()
console.print("[green]Check backup_json/ directory for created files:[/green]")
console.print(" [dim]-[/dim] backup_system_config.json")
console.print(" [dim]-[/dim] backup_system_data.json")
console.print(" [dim]-[/dim] backup_system_log.json")
console.print()
@@ -0,0 +1,119 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: statistics_handler.py - Backup statistics tracking
# Date: 2025-11-18
# Version: 1.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted update_data_file() statistics tracking
# * Handles runtime state and statistics updates
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Statistics Handler - Backup statistics and runtime state tracking
Manages backup operation statistics and runtime state persistence.
"""
# =============================================
# IMPORTS
# =============================================
import logging
import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
# Import handlers
from ..json.json_handler import load_json, save_json
from ..models.backup_models import BackupResult
from ..utils.system_utils import safe_print
# =============================================
# STATISTICS OPERATIONS
# =============================================
def update_data_file(backup_result: BackupResult):
"""Update data file with backup statistics.
Args:
backup_result: BackupResult instance with operation statistics
"""
try:
# Load current data
data = load_json("backup_core", "data")
if not data:
data = {
"last_updated": datetime.datetime.now().isoformat(),
"runtime_state": {},
"statistics": {},
"recent_backups": []
}
# Update runtime state
data["last_updated"] = datetime.datetime.now().isoformat()
if "runtime_state" not in data:
data["runtime_state"] = {}
data["runtime_state"]["current_status"] = "completed" if backup_result.success else "failed"
data["runtime_state"]["last_backup"] = datetime.datetime.now().isoformat()
data["runtime_state"]["active_mode"] = backup_result.mode
data["runtime_state"]["total_files_backed_up"] = backup_result.files_copied
data["runtime_state"]["backup_in_progress"] = False
# Update statistics
if "statistics" not in data:
data["statistics"] = {
"total_backups": 0,
"successful_backups": 0,
"failed_backups": 0,
"snapshot_backups": 0,
"versioned_backups": 0,
"total_files_processed": 0
}
data["statistics"]["total_backups"] += 1
if backup_result.success:
data["statistics"]["successful_backups"] += 1
else:
data["statistics"]["failed_backups"] += 1
# Mode-specific counters
if backup_result.mode == "snapshot":
data["statistics"]["snapshot_backups"] += 1
elif backup_result.mode == "versioned":
data["statistics"]["versioned_backups"] += 1
data["statistics"]["total_files_processed"] += backup_result.files_checked
# Add to recent backups (keep last 10)
if "recent_backups" not in data:
data["recent_backups"] = []
data["recent_backups"].append({
"timestamp": datetime.datetime.now().isoformat(),
"mode": backup_result.mode,
"success": backup_result.success,
"files_copied": backup_result.files_copied,
"errors": backup_result.errors
})
data["recent_backups"] = data["recent_backups"][-10:] # Keep last 10
save_json("backup_core", "data", data)
except Exception as e:
safe_print(f"Failed to update data file: {e}")
logger.error(f"[statistics_handler] Failed to update data file: {e}")
# =============================================
# MODULE INITIALIZATION
# =============================================
# No module-level initialization needed
View File
+108
View File
@@ -0,0 +1,108 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: backup_models.py - Backup system data models and structures
# Date: 2025-11-16
# Version: 2.0.1
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.0.1 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang and sys.path manipulation
# - v2.0.0 (2025-11-16): Migrated to seed 3-layer architecture
# - v1.0.0 (2025-10-14): Initial extraction from backup.py
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Backup System Data Models
Shared data structures used across all backup modules.
Simple data containers with no complex logic or dependencies.
Implements pure data model pattern following seed architecture standards.
"""
# =============================================
# IMPORTS
# =============================================
from pathlib import Path
import datetime
from typing import List
# =============================================
# DATA MODELS
# =============================================
class BackupResult:
"""Result of a backup operation
Tracks statistics and errors for backup operations.
Used by all modules to report success/failure and collect metrics.
Attributes:
files_checked (int): Number of files checked during backup
files_copied (int): Number of files successfully copied
files_added (int): New files added in versioned mode
files_skipped (int): Number of files skipped
files_deleted (int): Number of files deleted
errors (int): Total error count
error_details (List[str]): Detailed error messages
warnings (List[str]): Non-critical warnings
critical_errors (List[str]): Critical errors that failed the backup
start_time (datetime.datetime): When backup operation started
backup_path (str): Path to backup destination
mode (str): Backup mode (e.g., 'full', 'versioned')
success (bool): Overall success status of backup
"""
def __init__(self):
# File statistics
self.files_checked: int = 0
self.files_copied: int = 0
self.files_added: int = 0 # New files added (versioned mode)
self.files_skipped: int = 0
self.files_deleted: int = 0
# Error tracking
self.errors: int = 0
self.error_details: List[str] = []
self.warnings: List[str] = []
self.critical_errors: List[str] = []
# Metadata
self.start_time = datetime.datetime.now()
self.backup_path: str = ""
self.mode: str = ""
self.success: bool = True
def add_error(self, error_msg: str, is_critical: bool = False):
"""Add an error to the result
Args:
error_msg: Error message describing what failed
is_critical: If True, marks entire backup as failed
"""
self.errors += 1
self.error_details.append(error_msg)
if is_critical:
self.critical_errors.append(error_msg)
self.success = False
def add_warning(self, warning_msg: str):
"""Add a warning to the result
Args:
warning_msg: Warning message for non-critical issues
"""
self.warnings.append(warning_msg)
# =============================================
# MODULE INITIALIZATION
# =============================================
# Pure data models - no initialization needed
@@ -0,0 +1,928 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: drive_sync_client.py - Google Drive Sync Client Handler
# Date: 2026-02-20
# Version: 1.0.0
# Category: backup_system/handlers/operations
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Extracted GoogleDriveSync class from module
# * Moved implementation to handler layer per seed 3-layer architecture
# * Module now delegates to this handler for all Drive operations
#
# CODE STANDARDS:
# - Handlers must be independent and transportable
# - No prax imports in class methods (handler tier)
# - Google API interactions isolated here
# =============================================
"""
Google Drive Sync Client Handler
Contains the GoogleDriveSync class - the core implementation for
uploading backup files to Google Drive with OAuth2 authentication.
Called exclusively by the google_drive_sync module orchestrator.
"""
# =============================================
# IMPORTS
# =============================================
import sys
import copy
import fcntl
import json
import os
import ssl
import tempfile
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
# Google API imports
try:
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
GOOGLE_API_AVAILABLE = True
except ImportError:
GOOGLE_API_AVAILABLE = False
build = None
MediaFileUpload = None
Request = None
Credentials = None
InstalledAppFlow = None
# JSON handler for data persistence
from ..json.drive_sync_json import (
load_config as _load_config_fn,
save_config as _save_config_fn,
load_data as _load_data_fn,
save_data as _save_data_fn,
load_log as _load_log_fn,
save_log as _save_log_fn,
log_operation as _log_operation_fn,
)
# =============================================
# CONSTANTS
# =============================================
_BACKUP_ROOT = Path(__file__).resolve().parents[4] # src/aipass/backup/
JSON_DIR = _BACKUP_ROOT / "backup_json"
SCOPES = ['https://www.googleapis.com/auth/drive.file']
MODULE_NAME = "google_drive_sync"
CONFIG_FILE = JSON_DIR / f"{MODULE_NAME}_config.json"
DATA_FILE = JSON_DIR / f"{MODULE_NAME}_data.json"
LOG_FILE = JSON_DIR / f"{MODULE_NAME}_log.json"
_log_lock = threading.Lock()
# Convenience wrappers that use this module's JSON file paths
def _load_config():
"""Load config using module constants."""
return _load_config_fn(CONFIG_FILE)
def _save_config(config):
"""Save config using module constants."""
_save_config_fn(CONFIG_FILE, config)
def _load_data():
"""Load data using module constants."""
return _load_data_fn(DATA_FILE)
def _save_data(data):
"""Save data using module constants."""
_save_data_fn(DATA_FILE, data)
def _load_log(max_retries=3):
"""Load log using module constants."""
return _load_log_fn(LOG_FILE, max_retries)
def _save_log(log):
"""Save log using module constants."""
_save_log_fn(LOG_FILE, log)
def _log_operation(operation, details, success=True, correlation_id=None):
"""Log operation using module constants."""
_log_operation_fn(LOG_FILE, operation, details, success, correlation_id)
def _atomic_json_write(file_path: Path, data: Any):
"""Write JSON atomically via temp file + rename to prevent corruption."""
fd, tmp_path = tempfile.mkstemp(suffix='.tmp', dir=str(file_path.parent))
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, str(file_path))
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
class GoogleDriveSync:
"""Handles Google Drive integration for backup uploads"""
def __init__(self):
"""Initialize Google Drive sync with AIPass standards"""
self.config = _load_config()
self.data = _load_data()
# Paths
self.creds_path = Path.home() / '.aipass' / 'drive_creds.json'
self.client_secrets_path = Path(__file__).parent.parent / 'credentials.json'
# Runtime state
self._drive_service = None
self._thread_local = threading.local()
self._folder_cache_lock = threading.Lock()
self.backup_folder_id = None
self.project_folder_cache = self.data.get("runtime_state", {}).get("cached_folders", {})
self.file_tracker = self.data.get("runtime_state", {}).get("file_tracker", {})
self.last_error = None # Last error message for callers
self.tracker_was_reset = False # Set True when new folder = tracker invalidated
# Check if Google API is available
if not GOOGLE_API_AVAILABLE:
self.config["config"]["enabled"] = False
_save_config(self.config)
@property
def drive_service(self):
"""Return thread-local service if set, otherwise the main service"""
return getattr(self._thread_local, 'service', None) or self._drive_service
@drive_service.setter
def drive_service(self, value):
self._drive_service = value
def authenticate(self) -> bool:
"""Authenticate with Google Drive using OAuth credentials"""
if not GOOGLE_API_AVAILABLE:
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
return False
start_time = datetime.now()
creds = None
# Load existing credentials if they exist
if self.creds_path.exists():
try:
creds = Credentials.from_authorized_user_file(str(self.creds_path), SCOPES)
except Exception as e:
creds = None
# If no valid credentials, start OAuth flow
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
except Exception as e:
creds = None
if not creds:
# Check if client secrets file exists
if not self.client_secrets_path.exists():
return False
try:
flow = InstalledAppFlow.from_client_secrets_file(
str(self.client_secrets_path), SCOPES
)
creds = flow.run_local_server(port=0)
except Exception as e:
_log_operation("authenticate", {"message": f"OAuth flow failed: {e}", "error_details": {"exception_type": type(e).__name__, "stack_trace": str(e)}}, success=False)
return False
# Save credentials for next time
try:
# Create .aipass directory if it doesn't exist
self.creds_path.parent.mkdir(parents=True, exist_ok=True)
# Save credentials
with open(self.creds_path, 'w', encoding='utf-8') as f:
f.write(creds.to_json())
except Exception as e:
pass
# Build Drive service
try:
self.creds = creds # Store for building per-thread services
self.drive_service = build('drive', 'v3', credentials=creds)
# Update runtime state (ensure runtime_state exists first)
if "runtime_state" not in self.data:
self.data["runtime_state"] = {}
self.data["runtime_state"]["authenticated"] = True
_save_data(self.data)
# Log successful authentication
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
_log_operation("authenticate", {"message": "Successfully authenticated with Google Drive", "execution_time_ms": execution_time}, success=True)
return True
except Exception as e:
_log_operation("authenticate", {"message": f"Failed to build Drive service: {e}", "error_details": {"exception_type": type(e).__name__, "stack_trace": str(e)}}, success=False)
return False
def get_storage_quota(self) -> Optional[Dict[str, Any]]:
"""Get Google Drive storage quota information"""
try:
if not self.drive_service:
return None
about = self.drive_service.about().get(fields="storageQuota").execute()
quota = about.get('storageQuota', {})
# Convert bytes to GB for readability
limit = int(quota.get('limit', 0))
usage = int(quota.get('usage', 0))
limit_gb = limit / (1024**3)
usage_gb = usage / (1024**3)
free_gb = (limit - usage) / (1024**3)
percent_used = (usage / limit * 100) if limit > 0 else 0
return {
'limit_gb': limit_gb,
'usage_gb': usage_gb,
'free_gb': free_gb,
'percent_used': percent_used
}
except Exception as e:
return None
def _verify_folder_id(self, folder_id: str) -> bool:
"""Verify a cached folder ID exists and is NOT trashed."""
if not self.drive_service or not folder_id:
return False
try:
result = self._api_call_with_retry(
self.drive_service.files().get(fileId=folder_id, fields='id,trashed')
)
return not result.get('trashed', False)
except Exception:
return False
def get_or_create_backup_folder(self) -> Optional[str]:
"""Get or create the main 'AIPass Backups' folder in Drive.
Returns folder ID or None on failure. Caller must handle None.
"""
if self.backup_folder_id:
# Verify cached ID isn't trashed
if self._verify_folder_id(self.backup_folder_id):
return self.backup_folder_id
self.backup_folder_id = None
try:
if not self.drive_service:
raise RuntimeError("Drive service not authenticated")
# Search for existing folder (excludes trashed)
results = self._api_call_with_retry(self.drive_service.files().list(
q="name='AIPass Backups' and mimeType='application/vnd.google-apps.folder' and trashed=false",
fields="files(id, name)"
))
folders = results.get('files', [])
if folders:
self.backup_folder_id = folders[0]['id']
else:
# Creating NEW folder - old one is gone/trashed
folder_metadata = {
'name': 'AIPass Backups',
'mimeType': 'application/vnd.google-apps.folder'
}
folder = self._api_call_with_retry(self.drive_service.files().create(
body=folder_metadata,
fields='id'
))
self.backup_folder_id = folder.get('id')
if not self.backup_folder_id:
raise RuntimeError("Drive API returned no folder ID after create")
# Reset tracker - all old drive_ids point to dead files
old_count = len(self.file_tracker)
if old_count > 0:
self.file_tracker.clear()
self.project_folder_cache.clear()
if "runtime_state" in self.data:
self.data["runtime_state"]["file_tracker"] = {}
self.data["runtime_state"]["cached_folders"] = {}
self._save_file_tracker()
self.tracker_was_reset = True
_log_operation("tracker_reset", {
"message": f"New backup folder created - reset {old_count} tracker entries",
"old_tracker_count": old_count,
"new_folder_id": self.backup_folder_id
})
# Verify the folder is actually accessible
if not self._verify_folder_id(self.backup_folder_id):
raise RuntimeError(f"Backup folder {self.backup_folder_id} created but not accessible")
return self.backup_folder_id
except Exception as e:
self.last_error = f"Backup folder setup failed: {e}"
return None
def get_or_create_project_folder(self, project_name: str) -> Optional[str]:
"""Get or create project subfolder within AIPass Backups."""
# Lock covers cache check + search + create to prevent duplicate folders
with self._folder_cache_lock:
if project_name in self.project_folder_cache:
folder_id = self.project_folder_cache[project_name]
if self._verify_folder_id(folder_id):
return folder_id
# Cached ID is trashed or gone - clear it
del self.project_folder_cache[project_name]
if project_name in self.data.get("runtime_state", {}).get("cached_folders", {}):
del self.data["runtime_state"]["cached_folders"][project_name]
_save_data(self.data)
backup_folder_id = self.get_or_create_backup_folder()
if not backup_folder_id:
return None
try:
if not self.drive_service:
return None
results = self._api_call_with_retry(self.drive_service.files().list(
q=f"name='{project_name}' and mimeType='application/vnd.google-apps.folder' and '{backup_folder_id}' in parents and trashed=false",
fields="files(id, name)"
))
folders = results.get('files', [])
if folders:
project_folder_id = folders[0]['id']
else:
folder_metadata = {
'name': project_name,
'mimeType': 'application/vnd.google-apps.folder',
'parents': [backup_folder_id]
}
folder = self._api_call_with_retry(self.drive_service.files().create(
body=folder_metadata,
fields='id'
))
project_folder_id = folder.get('id')
self.project_folder_cache[project_name] = project_folder_id
return project_folder_id
except Exception as e:
return None
def get_or_create_nested_folder(self, parent_folder_id: str, folder_path: str) -> Optional[str]:
"""Create nested folder structure in Google Drive"""
if not folder_path or folder_path == '.':
return parent_folder_id
# Lock covers cache check + search + create to prevent duplicate folders
cache_key = f"{parent_folder_id}:{folder_path}"
with self._folder_cache_lock:
if cache_key in self.project_folder_cache:
folder_id = self.project_folder_cache[cache_key]
if self._verify_folder_id(folder_id):
return folder_id
# Cached ID is trashed or gone - clear it
del self.project_folder_cache[cache_key]
if cache_key in self.data.get("runtime_state", {}).get("cached_folders", {}):
del self.data["runtime_state"]["cached_folders"][cache_key]
_save_data(self.data)
try:
path_parts = folder_path.split('/')
current_parent_id = parent_folder_id
for part in path_parts:
if not part:
continue
# Check cache for this intermediate path segment too
segment_key = f"{current_parent_id}:{part}"
if segment_key in self.project_folder_cache:
cached_id = self.project_folder_cache[segment_key]
if self._verify_folder_id(cached_id):
current_parent_id = cached_id
continue
del self.project_folder_cache[segment_key]
if not self.drive_service:
return parent_folder_id
results = self._api_call_with_retry(self.drive_service.files().list(
q=f"name='{part}' and mimeType='application/vnd.google-apps.folder' and '{current_parent_id}' in parents and trashed=false",
fields="files(id, name)"
))
folders = results.get('files', [])
if folders:
current_parent_id = folders[0]['id']
else:
folder_metadata = {
'name': part,
'mimeType': 'application/vnd.google-apps.folder',
'parents': [current_parent_id]
}
if not self.drive_service:
return parent_folder_id
folder = self._api_call_with_retry(self.drive_service.files().create(
body=folder_metadata,
fields='id'
))
current_parent_id = folder.get('id')
# Cache each intermediate segment
self.project_folder_cache[segment_key] = current_parent_id
self.project_folder_cache[cache_key] = current_parent_id
return current_parent_id
except Exception as e:
return parent_folder_id # Fallback to parent folder
def upload_backup_file(self, local_file: Path, project_name: str, note: str = "", backup_root: Optional[Path] = None) -> bool:
"""Upload a backup file to the appropriate project folder with nested structure"""
if not self.drive_service:
return False
project_folder_id = self.get_or_create_project_folder(project_name)
if not project_folder_id:
return False
try:
# Calculate relative path from backup root to maintain folder structure
if backup_root and backup_root in local_file.parents:
relative_path = local_file.relative_to(backup_root)
folder_path = str(relative_path.parent) if relative_path.parent != Path('.') else ""
# Convert Windows paths to forward slashes for consistency
folder_path = folder_path.replace('\\', '/')
else:
folder_path = ""
# Get or create the nested folder structure
target_folder_id = self.get_or_create_nested_folder(project_folder_id, folder_path)
if not target_folder_id:
return False
file_metadata = {
'name': local_file.name,
'parents': [target_folder_id],
'description': f'AIPass backup - {note}' if note else 'AIPass backup'
}
media = MediaFileUpload(str(local_file), resumable=True)
# Get file tracker info to avoid API calls when possible
if backup_root and backup_root in local_file.parents:
relative_path = local_file.relative_to(backup_root)
file_key = str(relative_path).replace('\\', '/')
else:
file_key = local_file.name
existing_file = None
tracked_file = self.file_tracker.get(file_key)
# Use cached drive_id if file is tracked, otherwise find it
if tracked_file and tracked_file.get("drive_id"):
# File is tracked, use cached drive_id
existing_file = {"id": tracked_file["drive_id"]}
else:
# File not tracked or no drive_id, search Drive
existing_file = self._find_existing_file(local_file.name, target_folder_id)
if existing_file is None and tracked_file:
# File was previously tracked but can't find it on Drive
# Skip rather than create duplicate - will retry next sync
return False
# For files called by new sync system, we already know they need upload
# Skip the size comparison check to avoid redundant work
if existing_file:
# Update existing file - don't include parents field for updates
update_metadata = {
'name': local_file.name,
'description': f'AIPass backup - {note}' if note else 'AIPass backup'
}
file = self._api_call_with_retry(self.drive_service.files().update(
fileId=existing_file['id'],
body=update_metadata,
media_body=media
))
action = "Updated"
else:
# Create new file
file = self._api_call_with_retry(self.drive_service.files().create(
body=file_metadata,
media_body=media,
fields='id'
))
action = "Uploaded"
# Update file tracker with new drive_id
drive_file_id = file.get('id') if 'file' in locals() else (existing_file.get('id') if existing_file else None)
if drive_file_id and backup_root:
self._update_file_tracker(local_file, backup_root, drive_file_id)
# Update statistics (ensure statistics exists first)
if "statistics" not in self.data:
self.data["statistics"] = {
"total_uploads": 0,
"successful_uploads": 0,
"failed_uploads": 0,
"total_bytes_uploaded": 0
}
self.data["statistics"]["total_uploads"] += 1
self.data["statistics"]["successful_uploads"] += 1
self.data["statistics"]["total_bytes_uploaded"] += local_file.stat().st_size
# Stats saved to disk in batches by sync_backup_files(), not per-file
# Log operation
_log_operation(f"upload_file", {
"message": f"{action} {local_file.name} to {project_name}",
"file_size": local_file.stat().st_size,
"project": project_name
}, success=True)
return True
except Exception as e:
# Update failure statistics (ensure statistics exists first)
if "statistics" not in self.data:
self.data["statistics"] = {
"total_uploads": 0,
"successful_uploads": 0,
"failed_uploads": 0,
"total_bytes_uploaded": 0
}
self.data["statistics"]["total_uploads"] += 1
self.data["statistics"]["failed_uploads"] += 1
_save_data(self.data)
# Log failure
_log_operation("upload_file", {
"message": f"Failed to upload {local_file.name}",
"error_details": {"exception_type": type(e).__name__, "stack_trace": str(e)}
}, success=False)
return False
def _find_existing_file(self, filename: str, parent_folder_id: str) -> Optional[Dict[str, Any]]:
"""Find existing file in Drive folder with metadata"""
try:
if not self.drive_service:
return None
# Escape single quotes in filename for Drive API query
safe_filename = filename.replace("\\", "\\\\").replace("'", "\\'")
results = self._api_call_with_retry(self.drive_service.files().list(
q=f"name='{safe_filename}' and '{parent_folder_id}' in parents and trashed=false",
fields="files(id, name, size, modifiedTime, md5Checksum)"
))
files = results.get('files', [])
return files[0] if files else None
except Exception as e:
return None
def _load_file_tracker(self) -> Dict[str, Dict[str, Any]]:
"""Load file tracker from data JSON"""
return self.data.get("runtime_state", {}).get("file_tracker", {})
def _save_file_tracker(self):
"""Save file tracker to data JSON"""
if "runtime_state" not in self.data:
self.data["runtime_state"] = {}
self.data["runtime_state"]["file_tracker"] = self.file_tracker
_save_data(self.data)
def _build_thread_service(self):
"""Build a separate Drive service instance for use in a worker thread.
Loads fresh credentials from disk to avoid sharing credential state
(token refresh races) and creates a fully isolated HTTP/SSL connection.
"""
creds = Credentials.from_authorized_user_file(str(self.creds_path), SCOPES)
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
return build('drive', 'v3', credentials=creds)
@staticmethod
def _is_ssl_error(exc: Exception) -> bool:
"""Check if an exception is a transient SSL/connection error."""
if isinstance(exc, (ssl.SSLError, BrokenPipeError, ConnectionResetError)):
return True
ssl_keywords = (
'DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
'WRONG_VERSION_NUMBER',
'EOF occurred',
'ssl.SSLError',
'BrokenPipeError',
'ConnectionReset',
)
msg = str(exc)
return any(kw in msg for kw in ssl_keywords)
def _api_call_with_retry(self, request, max_retries: int = 3):
"""Execute a Google API request with exponential backoff on SSL errors."""
for attempt in range(max_retries + 1):
try:
return request.execute()
except Exception as e:
if attempt < max_retries and self._is_ssl_error(e):
wait = 2 ** attempt
time.sleep(wait)
# Rebuild this thread's service on SSL failure
self._thread_local.service = self._build_thread_service()
continue
raise
def _check_file_needs_upload_local(self, local_file: Path, backup_root: Path) -> bool:
"""Check if file needs upload using local tracker (no API calls)"""
try:
# Calculate relative path for tracking
if backup_root and backup_root in local_file.parents:
relative_path = local_file.relative_to(backup_root)
file_key = str(relative_path).replace('\\', '/')
else:
file_key = local_file.name
# Get current file stats
current_size = local_file.stat().st_size
current_mtime = local_file.stat().st_mtime
# Check if file is in tracker
if file_key not in self.file_tracker:
return True # New file, needs upload
tracked_file = self.file_tracker[file_key]
# Compare local file stats with tracker
if (current_size != tracked_file.get("local_size", 0) or
current_mtime != tracked_file.get("local_mtime", 0)):
return True # File changed, needs upload
# File unchanged according to tracker
return False
except Exception as e:
return True # On error, assume file needs upload
def _update_file_tracker(self, local_file: Path, backup_root: Path, drive_file_id: str):
"""Update file tracker after successful upload"""
try:
# Calculate relative path for tracking
if backup_root and backup_root in local_file.parents:
relative_path = local_file.relative_to(backup_root)
file_key = str(relative_path).replace('\\', '/')
else:
file_key = local_file.name
# Get current file stats
current_size = local_file.stat().st_size
current_mtime = local_file.stat().st_mtime
# Update tracker
self.file_tracker[file_key] = {
"local_size": current_size,
"local_mtime": current_mtime,
"drive_id": drive_file_id,
"drive_size": current_size,
"last_sync": datetime.now().isoformat()
}
# Tracker updated in-memory; disk save is batched by sync_backup_files()
except Exception as e:
pass
def _clean_file_tracker(self, existing_files: set):
"""Remove tracker entries for files that no longer exist"""
try:
keys_to_remove = []
for file_key in self.file_tracker.keys():
if file_key not in existing_files:
keys_to_remove.append(file_key)
for key in keys_to_remove:
del self.file_tracker[key]
if keys_to_remove:
self._save_file_tracker()
except Exception as e:
pass
def prepare_sync(self, backup_dir: Path, force_sync: bool = False, limit: int = 0):
"""Scan files and determine what needs uploading. No API calls.
Args:
backup_dir: Directory to scan
force_sync: Force upload all files regardless of tracker
limit: Only consider first N files (0 = no limit). Same N files every run.
Returns:
tuple: (files_to_upload, skipped_count, total_count)
"""
if not backup_dir.exists():
return [], 0, 0
self.file_tracker = self._load_file_tracker()
# Collect all files first (sorted for deterministic order)
all_files = sorted(
[f for f in backup_dir.rglob("*") if f.is_file() and not f.name.startswith('.')],
key=lambda f: str(f)
)
# Apply limit - always the same files every run
if limit > 0:
all_files = all_files[:limit]
files_to_upload = []
skipped_count = 0
total_count = len(all_files)
existing_files = set()
for backup_file in all_files:
if backup_dir in backup_file.parents:
relative_path = backup_file.relative_to(backup_dir)
file_key = str(relative_path).replace('\\', '/')
existing_files.add(file_key)
if force_sync or self._check_file_needs_upload_local(backup_file, backup_dir):
files_to_upload.append(backup_file)
else:
skipped_count += 1
# Only clean tracker when doing full sync (not limited)
if limit == 0:
self._clean_file_tracker(existing_files)
return files_to_upload, skipped_count, total_count
def _file_needs_upload(self, local_file: Path, drive_file: Optional[Dict[str, Any]]) -> bool:
"""Check if a file needs to be uploaded based on size comparison"""
if not drive_file:
return True # File doesn't exist in Drive, needs upload
try:
local_size = local_file.stat().st_size
drive_size = int(drive_file.get('size', 0))
# If sizes are different, file has changed
if local_size != drive_size:
return True
# If sizes are same, assume file is unchanged (for now)
# TODO: Could add MD5 comparison for small files for better accuracy
return False
except Exception as e:
return True # Upload on error to be safe
def sync_backup_files(self, backup_dir: Path, project_name: str, note: str = "", force_sync: bool = False,
prepared_files=None, skipped_count=0, total_count=0, progress_fn=None) -> dict:
"""Sync backup files to Drive using local-first change detection.
Args:
backup_dir: Directory containing backup files
project_name: Project name for Drive folder
note: Optional sync note
force_sync: Force upload all files regardless of tracker
prepared_files: Pre-scanned file list from prepare_sync(). If None, does own scan.
skipped_count: From prepare_sync()
total_count: From prepare_sync()
progress_fn: Callback fn(completed, total, success_count) called periodically
Returns:
dict with keys: success, uploaded, skipped, failed, total
"""
result = {"success": False, "uploaded": 0, "skipped": skipped_count, "failed": 0, "total": total_count, "error": None}
if not backup_dir.exists():
result["error"] = f"Backup directory not found: {backup_dir}"
return result
# Pre-flight: verify Drive folder is accessible before uploading anything
project_folder_id = self.get_or_create_project_folder(project_name)
if not project_folder_id:
result["error"] = self.last_error or f"Failed to create/access Drive folder for '{project_name}'"
return result
# Phase 1: Use pre-scanned files if provided, otherwise scan locally
if prepared_files is not None:
files_to_upload = prepared_files
else:
files_to_upload, skipped_count, total_count = self.prepare_sync(backup_dir, force_sync)
result["skipped"] = skipped_count
result["total"] = total_count
upload_count = len(files_to_upload)
# Phase 2: Upload only the files that need it (WITH API CALLS)
success_count = 0
completed_count = 0
failed_count = 0
BATCH_SAVE_INTERVAL = 50 # Save tracker to disk every N uploads
MAX_WORKERS = 3 # Parallel upload threads (kept low to avoid SSL contention)
tracker_lock = threading.Lock()
if upload_count > 0:
# Pre-create project folder before threads start (avoid duplicate creation)
self.get_or_create_project_folder(project_name)
def _upload_one(backup_file):
"""Upload a single file using a thread-local Drive service"""
# Each thread gets its own Drive service with isolated SSL connections
if not hasattr(self._thread_local, 'service'):
self._thread_local.service = self._build_thread_service()
upload_result = self.upload_backup_file(backup_file, project_name, note, backup_dir)
return (backup_file, upload_result)
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(_upload_one, f): f for f in files_to_upload}
for future in as_completed(futures):
_, upload_ok = future.result()
with tracker_lock:
completed_count += 1
if upload_ok:
success_count += 1
else:
failed_count += 1
# Batch save every BATCH_SAVE_INTERVAL files
if completed_count % BATCH_SAVE_INTERVAL == 0:
self._save_file_tracker()
_save_data(self.data)
# Progress callback on every file for smooth progress bar
if progress_fn:
progress_fn(completed_count, upload_count, success_count)
# Final save and progress update after all uploads complete
self._save_file_tracker()
_save_data(self.data)
if progress_fn:
progress_fn(completed_count, upload_count, success_count)
# Update last sync time
if "runtime_state" not in self.data:
self.data["runtime_state"] = {}
self.data["runtime_state"]["last_sync"] = datetime.now().isoformat()
_save_data(self.data)
# Log sync operation
total_success = success_count + skipped_count
_log_operation("sync_backup", {
"message": f"Synced {total_success}/{total_count} files for {project_name} ({success_count} uploaded, {skipped_count} skipped)",
"uploaded_count": success_count,
"skipped_count": skipped_count,
"total_count": total_count,
"project": project_name,
"force_sync": force_sync
}, success=(total_success == total_count))
result["success"] = (failed_count == 0)
result["uploaded"] = success_count
result["failed"] = failed_count
return result
@@ -0,0 +1,143 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: drive_sync_ops.py - Google Drive Sync Business Operations Handler
# Date: 2026-02-20
# Version: 1.0.0
# Category: backup_system/handlers/operations
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Extracted from google_drive_sync module
# * Moved get_status, clear_file_tracker, show_file_tracker_stats,
# test_drive_sync to handler layer
# * Follows seed 3-layer architecture standards
#
# CODE STANDARDS:
# - Handlers must be independent and transportable
# - No prax imports (handler tier)
# - Delegates to json handler for file operations
# =============================================
"""
Google Drive Sync Business Operations Handler
Implements status, tracker management, and test operations for the
google_drive_sync module. Called by the module orchestrator.
"""
from pathlib import Path
from typing import Any, Dict
from ..json.drive_sync_json import (
load_config,
load_data,
save_data,
log_operation,
)
# JSON file paths (resolved relative to backup root)
_BACKUP_ROOT = Path(__file__).resolve().parents[4] # src/aipass/backup/
_JSON_DIR = _BACKUP_ROOT / "backup_json"
_MODULE_NAME = "google_drive_sync"
_CONFIG_FILE = _JSON_DIR / f"{_MODULE_NAME}_config.json"
_DATA_FILE = _JSON_DIR / f"{_MODULE_NAME}_data.json"
_LOG_FILE = _JSON_DIR / f"{_MODULE_NAME}_log.json"
def get_status() -> Dict[str, Any]:
"""Get current module status for monitoring.
Returns:
Dict with module status fields
"""
data = load_data(_DATA_FILE)
config = load_config(_CONFIG_FILE)
return {
"name": _MODULE_NAME,
"category": "backup_system",
"enabled": config.get("config", {}).get("enabled", False),
"authenticated": data.get("runtime_state", {}).get("authenticated", False),
"last_sync": data.get("runtime_state", {}).get("last_sync"),
"statistics": data.get("statistics", {})
}
def clear_file_tracker() -> bool:
"""Clear the file tracker cache for a fresh sync.
Returns:
bool: True if cleared successfully, False on error
"""
try:
data = load_data(_DATA_FILE)
if "runtime_state" in data and "file_tracker" in data["runtime_state"]:
data["runtime_state"]["file_tracker"] = {}
save_data(_DATA_FILE, data)
return True
except Exception:
return False
def get_file_tracker_stats() -> Dict[str, Any]:
"""Get statistics about the file tracker.
Returns:
Dict with tracker count and sample entries
"""
try:
data = load_data(_DATA_FILE)
tracker = data.get("runtime_state", {}).get("file_tracker", {})
sample = []
for i, (file_key, info) in enumerate(list(tracker.items())[:5]):
last_sync = info.get("last_sync", "unknown")[:19] if info.get("last_sync") else "unknown"
sample.append({"file": file_key, "last_sync": last_sync})
return {
"total": len(tracker),
"sample": sample,
"truncated": len(tracker) > 5
}
except Exception:
return {"total": 0, "sample": [], "truncated": False}
def test_drive_connection(sync_instance: Any) -> bool:
"""Test Google Drive connectivity using an authenticated sync instance.
Args:
sync_instance: An authenticated GoogleDriveSync instance
Returns:
bool: True if connection test succeeded, False otherwise
"""
try:
folder_id = sync_instance.get_or_create_backup_folder()
if folder_id:
log_operation(
_LOG_FILE,
"test_sync",
{"message": "Drive sync test successful", "folder_id": folder_id},
success=True
)
return True
else:
log_operation(
_LOG_FILE,
"test_sync",
{"message": "Failed to create backup folder"},
success=False
)
return False
except Exception as e:
log_operation(
_LOG_FILE,
"test_sync",
{
"message": f"Test failed: {e}",
"error_details": {"exception_type": type(e).__name__, "stack_trace": str(e)}
},
success=False
)
return False
@@ -0,0 +1,177 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: file_cleanup.py - File deletion operations
# Date: 2025-11-23
# Version: 1.0.2
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.2 (2025-11-23): Added dry-run support for cleanup operations
# * Added dry_run parameter to cleanup_deleted_files()
# * Shows "Would delete" messages instead of actually deleting in dry-run
# * All three passes (directories, files, empty dirs) respect dry-run mode
# - v1.0.1 (2025-11-23): CRITICAL BUG FIX - respect exceptions in empty dir cleanup
# * Third pass now checks should_ignore() before removing empty directories
# * Prevents deletion of empty template directories
# * Added source_dir mapping to check exceptions properly
# * Only removes empty dirs if source doesn't exist OR should be ignored
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted file deletion logic for dynamic mode
# * Handles cleanup of deleted source files
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
File Cleanup - File deletion operations for dynamic backups
Handles cleanup of backup files when source files are deleted.
"""
# =============================================
# IMPORTS
# =============================================
import logging
from pathlib import Path
from typing import Callable
logger = logging.getLogger(__name__)
from ..utils.system_utils import temporarily_writable, safe_print
from ..models.backup_models import BackupResult
# =============================================
# FILE CLEANUP OPERATIONS
# =============================================
def cleanup_deleted_files(backup_path: Path, source_dir: Path, should_ignore: Callable,
result: BackupResult, dry_run: bool = False) -> None:
"""Clean up backup files when source files no longer exist (dynamic mode only).
Args:
backup_path: Backup destination path
source_dir: Source directory root
should_ignore: Function to check if path should be ignored
result: BackupResult to track deletions and errors
dry_run: If True, only show what would be deleted without actually deleting
"""
import shutil
import stat
if not backup_path.exists():
return
def handle_remove_readonly(func, path, exc_info):
"""Error handler for shutil.rmtree to handle read-only files."""
import os
# Make the file/directory writable (and executable if directory) and try again
# Also make parent directory writable
os.chmod(path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
parent = Path(path).parent
if parent.exists():
os.chmod(parent, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
func(path)
try:
# Make backup_path writable for the duration of cleanup
backup_path.chmod(stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
# First pass: Recursively check ALL directories and remove if ignored
# Sort by depth (shallowest first) so we can remove parents and skip children
all_dirs = sorted([d for d in backup_path.rglob('*') if d.is_dir()],
key=lambda p: len(p.parts))
removed_dirs = set()
for backup_dir in all_dirs:
# Skip if this directory is inside a directory we already removed
if any(str(backup_dir).startswith(str(removed)) for removed in removed_dirs):
continue
try:
rel_path = backup_dir.relative_to(backup_path)
source_dir_path = source_dir / rel_path
# Check if source doesn't exist or should be ignored
if not source_dir_path.exists() or should_ignore(source_dir_path):
if dry_run:
safe_print(f"Would delete directory: {backup_dir}/")
else:
# Remove entire tree
shutil.rmtree(backup_dir, onerror=handle_remove_readonly)
safe_print(f"Deleted directory: {backup_dir}/")
removed_dirs.add(backup_dir)
except Exception as e:
# Don't fail entire backup on one directory error
pass
# Second pass: delete individual files that no longer exist or should be ignored
for backup_file in backup_path.rglob('*'):
if backup_file.is_file():
try:
rel_path = backup_file.relative_to(backup_path)
source_file = source_dir / rel_path
if not source_file.exists() or should_ignore(source_file):
if dry_run:
safe_print(f"Would delete: {backup_file}")
else:
# Use context manager to handle read-only files before deletion
with temporarily_writable(backup_file.parent):
with temporarily_writable(backup_file):
backup_file.unlink()
safe_print(f"Deleted: {backup_file}")
result.files_deleted += 1
except PermissionError as e:
error_msg = f"Permission denied deleting {backup_file}: {e}"
result.add_error(error_msg)
safe_print(f"{error_msg}")
except Exception as e:
error_msg = f"Error deleting {backup_file}: {e}"
result.add_warning(error_msg)
safe_print(f"{error_msg}")
# Third pass: remove empty directories (bottom-up)
# CRITICAL FIX: Now checks should_ignore() to respect exceptions
all_dirs = [d for d in backup_path.rglob('*') if d.is_dir()]
all_dirs.sort(key=lambda p: len(p.parts), reverse=True)
for backup_dir in all_dirs:
try:
if not any(backup_dir.iterdir()): # Empty directory
# Map to source directory to check if it should be preserved
rel_path = backup_dir.relative_to(backup_path)
source_dir_path = source_dir / rel_path
# Only remove if source doesn't exist OR should be ignored
# This respects IGNORE_EXCEPTIONS (like templates/**)
if not source_dir_path.exists() or should_ignore(source_dir_path):
if dry_run:
safe_print(f"Would delete empty: {backup_dir}/")
else:
with temporarily_writable(backup_dir.parent):
backup_dir.chmod(stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
backup_dir.rmdir()
safe_print(f"Deleted empty: {backup_dir}/")
# else: Directory is in exceptions, preserve it even if empty
except OSError:
pass # Directory not empty or other OS error
except Exception as e:
pass # Silently skip other errors
except Exception as e:
error_msg = f"Error scanning for deleted files: {e}"
result.add_warning(error_msg)
logger.warning(f"[file_cleanup] {error_msg}")
# =============================================
# MODULE INITIALIZATION
# =============================================
# No module-level initialization needed
@@ -0,0 +1,353 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: file_operations.py - Core backup file operations
# Date: 2025-11-23
# Version: 2.0.4
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.0.4 (2025-11-23): Fix baseline snapshot messages for VS Code clickability
# * Changed baseline snapshot message from {baseline_name} to {baseline_path}
# * Users can now Ctrl+click on baseline paths to jump directly to files
# * Shows full path (e.g., /home/aipass/backups/project/README-baseline-2025-11-23.md)
# - v2.0.3 (2025-11-23): CRITICAL performance fix for versioned backup
# * Skip copying unchanged files (only copy if mtime differs)
# * Fixes 5s -> <1s regression (was copying all 5000+ files every time)
# * Added file_changed flag to track copy necessity
# * Only copy if is_new_file or file_changed
# - v2.0.2 (2025-11-23): Added per-file output to snapshot mode
# * Snapshot backup now shows each file as it's copied
# * Displays "Copied (new)" or "Copied (updated)" for each file
# * Matches the verbosity of versioned backup mode
# - v2.0.1 (2025-11-23): Fixed automatic VS Code diff opening
# * Removed automatic VS Code integration from copy_versioned_file()
# * Diffs are now created silently without opening in editor
# * VS Code integration still available via separate command
# - v2.0.0 (2025-11-16): Extraction from backup_operations.py
# * Extracted core file operations (lines 49-271)
# * copy_file_with_structure for snapshot mode
# * copy_versioned_file with baseline snapshots and diff generation
# * Extensive Linux permission handling with temporarily_writable()
# * Retry logic for filesystem errors
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
File Operations Module - Core backup file handling
Core file copy operations with version management and permission handling.
Handles both snapshot (simple copy) and versioned (with diffs) backup modes.
Functions:
copy_file_with_structure: Copy file with directory structure (snapshot mode)
copy_versioned_file: Copy file with versioning and diff tracking
"""
# =============================================
# IMPORTS
# =============================================
import logging
import shutil
import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
# Import from handlers modules
from ..utils.system_utils import temporarily_writable, safe_print
from ..diff.diff_generator import should_create_diff, generate_diff_content
from ..models.backup_models import BackupResult
# =============================================
# SNAPSHOT MODE OPERATIONS
# =============================================
def copy_file_with_structure(source_file: Path, target_file: Path, backup_path: Path, result: BackupResult) -> bool:
"""Copy file with directory structure (snapshot mode).
Simple file copy operation with permission handling for Linux.
Creates the necessary directory structure and copies the source file
to the target location with proper permission handling.
Args:
source_file (Path): Source file to copy
target_file (Path): Target destination path
backup_path (Path): Root backup path (for permission handling)
result (BackupResult): BackupResult instance to track errors
Returns:
bool: True if copy succeeded, False otherwise
"""
try:
if len(str(target_file)) > 260:
error_msg = f"Path too long (>250 chars): {source_file}"
result.add_warning(error_msg)
safe_print(f" {error_msg}")
return False
# Use context manager to handle read-only ancestors
# Find the first existing ancestor (could be parent or further up)
check_path = target_file.parent
while not check_path.exists() and check_path.parent != check_path:
check_path = check_path.parent
# Make the first existing ancestor writable during mkdir
with temporarily_writable(check_path):
target_file.parent.mkdir(parents=True, exist_ok=True)
if not source_file.exists():
error_msg = f"Source file missing: {source_file}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
return False
# Track if this is a new file for appropriate messaging
is_new_file = not target_file.exists()
# Wrap the copy operation with permission handling
with temporarily_writable(target_file.parent):
if target_file.exists():
with temporarily_writable(target_file):
shutil.copy2(source_file, target_file)
else:
shutil.copy2(source_file, target_file)
# Show feedback for each file copied (with clickable path)
if is_new_file:
safe_print(f" Copied (new): {source_file}")
else:
safe_print(f" Copied (updated): {source_file}")
return True
except PermissionError as e:
error_msg = f"Permission denied copying {source_file}: {e}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
logger.error(f"[file_operations] {error_msg}")
return False
except OSError as e:
error_msg = f"OS error copying {source_file}: {e}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
logger.error(f"[file_operations] {error_msg}")
return False
except Exception as e:
error_msg = f"Unexpected error copying {source_file}: {e}"
result.add_error(error_msg, is_critical=True)
safe_print(f" CRITICAL: {error_msg}")
logger.error(f"[file_operations] CRITICAL: {error_msg}")
return False
# =============================================
# VERSIONED MODE OPERATIONS
# =============================================
def copy_versioned_file(source_file: Path, target_file: Path, backup_path: Path, result: BackupResult) -> bool:
"""Copy file with versioning - keep old versions as diffs when file changes.
Creates baseline snapshots for new files and diff files for changes.
Implements the file-organized structure (file.py/file.py_diffs/file.py_v*.diff).
The function handles:
- Baseline snapshot creation for new files
- Diff generation when existing files change
- Permission handling for read-only directories
- Retry logic for filesystem errors
- File state tracking in the BackupResult object
Args:
source_file (Path): Source file to copy
target_file (Path): Target destination path
backup_path (Path): Root backup path (for permission handling)
result (BackupResult): BackupResult instance to track errors and new files
Returns:
bool: True if copy succeeded, False otherwise
"""
try:
if len(str(target_file)) > 260:
error_msg = f"Path too long (>250 chars): {source_file}"
result.add_warning(error_msg)
safe_print(f" {error_msg}")
return False
# Use context manager to handle read-only ancestors
# Find the first existing ancestor (could be parent or further up)
check_path = target_file.parent
while not check_path.exists() and check_path.parent != check_path:
check_path = check_path.parent
# Make the first existing ancestor writable during mkdir
with temporarily_writable(check_path):
target_file.parent.mkdir(parents=True, exist_ok=True)
if not source_file.exists():
error_msg = f"Source file missing: {source_file}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
return False
# Track if this is a new file
is_new_file = not target_file.exists()
# If this is a new file, save a baseline snapshot (full file)
if is_new_file:
try:
# Create baseline snapshot with Drive-friendly naming (hyphens + preserved extension)
timestamp = datetime.datetime.now().strftime("%Y-%m-%d")
# Split filename and extension to preserve file type for Drive preview
name_parts = target_file.name.rsplit('.', 1)
if len(name_parts) == 2:
# Has extension: README.md -> README-baseline-2025-09-03.md
baseline_name = f"{name_parts[0]}-baseline-{timestamp}.{name_parts[1]}"
else:
# No extension: Makefile -> Makefile-baseline-2025-09-03
baseline_name = f"{target_file.name}-baseline-{timestamp}"
# Baseline goes in same folder as the file
baseline_path = target_file.parent / baseline_name
# Copy the full file as baseline with permission handling
with temporarily_writable(target_file.parent):
shutil.copy2(source_file, baseline_path)
safe_print(f" Created baseline snapshot: {baseline_path}")
except Exception as e:
error_msg = f"Error creating baseline snapshot for {target_file}: {e}"
result.add_warning(error_msg)
safe_print(f" {error_msg}")
logger.warning(f"[file_operations] {error_msg}")
# Track if file needs to be copied (changed or new)
file_changed = False
# If target file exists and is different, create versioned diff
if not is_new_file and target_file.exists():
try:
# Check if files are different
source_mtime = source_file.stat().st_mtime
target_mtime = target_file.stat().st_mtime
if source_mtime != target_mtime:
# Files are different - mark for copying
file_changed = True
# Check if we should create a diff
if should_create_diff(source_file):
# Create versioned diff showing changes
timestamp = datetime.datetime.fromtimestamp(target_mtime).strftime("%Y-%m-%d_%H-%M-%S")
# NEW FILE-ORGANIZED STRUCTURE
# Create file-specific diff folder next to the file: file.py_diffs/
file_diff_folder = target_file.parent / f"{target_file.name}_diffs"
with temporarily_writable(target_file.parent):
file_diff_folder.mkdir(parents=True, exist_ok=True)
versioned_name = f"{target_file.name}_v{timestamp}.diff"
versioned_path = file_diff_folder / versioned_name
# Generate diff between old (target) and new (source) versions
diff_content = generate_diff_content(target_file, source_file)
# Write diff to versioned file with permission handling
with temporarily_writable(file_diff_folder):
with open(versioned_path, 'w', encoding='utf-8') as f:
f.write(diff_content)
safe_print(f" Created diff: {versioned_path}")
else:
safe_print(f" Updated (no diff): {target_file.name}")
except Exception as e:
error_msg = f"Error creating version diff for {target_file}: {e}"
result.add_warning(error_msg)
safe_print(f" {error_msg}")
logger.warning(f"[file_operations] {error_msg}")
# Only copy if file is new or changed (skip unchanged files for performance)
if is_new_file or file_changed:
try:
with temporarily_writable(target_file.parent):
if target_file.exists():
with temporarily_writable(target_file):
shutil.copy2(source_file, target_file)
else:
shutil.copy2(source_file, target_file)
except (FileExistsError, OSError, PermissionError) as e:
# Handle various file system errors with context manager
try:
with temporarily_writable(target_file.parent):
if target_file.exists():
with temporarily_writable(target_file):
target_file.unlink() # Remove existing file
# Try copy again
shutil.copy2(source_file, target_file)
except Exception as retry_error:
error_msg = f"Failed to copy after retry {source_file} -> {target_file}: {retry_error}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
logger.error(f"[file_operations] {error_msg}")
return False
# Track new files separately
if is_new_file:
result.files_added += 1
return True
except PermissionError as e:
error_msg = f"Permission denied copying versioned file {source_file}: {e}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
logger.error(f"[file_operations] {error_msg}")
return False
except OSError as e:
error_msg = f"OS error copying versioned file {source_file}: {e}"
result.add_error(error_msg)
safe_print(f" {error_msg}")
logger.error(f"[file_operations] {error_msg}")
return False
except Exception as e:
error_msg = f"Unexpected error copying versioned file {source_file}: {e}"
result.add_error(error_msg, is_critical=True)
safe_print(f" CRITICAL: {error_msg}")
logger.error(f"[file_operations] {error_msg}")
return False
# =============================================
# FILE COMPARISON OPERATIONS
# =============================================
def file_needs_backup(source_file: Path, backup_file: Path, last_timestamps: dict, source_dir: Path) -> bool:
"""Check if file needs backup based on modification time.
Args:
source_file: Source file to check
backup_file: Backup destination file
last_timestamps: Dictionary of last backup timestamps
source_dir: Source directory root (for relative path calculation)
Returns:
True if file needs backup, False otherwise
"""
if not backup_file.exists():
return True
source_mtime = source_file.stat().st_mtime
rel_path = str(source_file.relative_to(source_dir))
last_mtime = last_timestamps.get(rel_path, 0)
return source_mtime > last_mtime
# =============================================
# MODULE INITIALIZATION
# =============================================
# No module-level initialization needed
@@ -0,0 +1,123 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: file_scanner.py - File system scanning operations
# Date: 2025-11-18
# Version: 1.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted file scanning logic (os.walk loops)
# * Handles file discovery with ignore patterns
# * Returns file list with metadata
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
File Scanner - File system discovery and scanning
Scans source directory for files to backup, applying ignore patterns.
"""
# =============================================
# IMPORTS
# =============================================
import os
from pathlib import Path
from typing import Callable
# =============================================
# FILE SCANNING OPERATIONS
# =============================================
def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool = False) -> tuple[list[Path], dict]:
"""Scan source directory for files to backup.
Args:
source_dir: Root directory to scan
should_ignore: Function to check if path should be ignored
show_progress: Whether to show progress spinner (default: False)
Returns:
Tuple of (file_list, skipped_items_dict)
- file_list: List of Path objects for files to backup
- skipped_items: Dict with 'directories' and 'files' sets
"""
files_to_backup = []
skipped_items = {"directories": set(), "files": set()}
if show_progress:
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.console import Console
console = Console()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True,
console=console
) as progress:
task = progress.add_task("Counting files...", total=None)
# Walk directory tree
for dirpath, dirnames, filenames in os.walk(source_dir):
# Filter directories (modify in-place to prune walk)
original_dirs = dirnames.copy()
dirnames[:] = [d for d in dirnames if not should_ignore(Path(dirpath) / d)]
# Track skipped directories
for d in original_dirs:
if d not in dirnames:
rel_dir = str(Path(dirpath).relative_to(source_dir) / d)
skipped_items["directories"].add(rel_dir)
# Process files
for filename in filenames:
file_path = Path(dirpath) / filename
if should_ignore(file_path):
rel_file = str(file_path.relative_to(source_dir))
skipped_items["files"].add(rel_file)
else:
files_to_backup.append(file_path)
# Update spinner description with count occasionally
if len(files_to_backup) % 100 == 0:
progress.update(task, description=f"Found {len(files_to_backup)} files...")
else:
# Walk directory tree without progress display
for dirpath, dirnames, filenames in os.walk(source_dir):
# Filter directories (modify in-place to prune walk)
original_dirs = dirnames.copy()
dirnames[:] = [d for d in dirnames if not should_ignore(Path(dirpath) / d)]
# Track skipped directories
for d in original_dirs:
if d not in dirnames:
rel_dir = str(Path(dirpath).relative_to(source_dir) / d)
skipped_items["directories"].add(rel_dir)
# Process files
for filename in filenames:
file_path = Path(dirpath) / filename
if should_ignore(file_path):
rel_file = str(file_path.relative_to(source_dir))
skipped_items["files"].add(rel_file)
else:
files_to_backup.append(file_path)
return files_to_backup, skipped_items
# =============================================
# MODULE INITIALIZATION
# =============================================
# No initialization needed - pure utility functions
@@ -0,0 +1,118 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: integration_ops.py - Backup Integration Operations Handler
# Date: 2026-02-20
# Version: 1.0.0
# Category: backup_system/handlers/operations
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Extracted from integrations module
# * Moved sync_to_drive() and set_backup_readonly() to handler layer
# * Follows seed 3-layer architecture standards
#
# CODE STANDARDS:
# - Handlers must be independent and transportable
# - No prax imports (handler tier)
# - No cross-handler imports except within same domain
# =============================================
"""
Backup Integration Operations Handler
Implementation logic for external integrations:
- Google Drive sync operations
- Backup directory read-only protection
Called by the integrations module orchestrator.
"""
import os
import stat
from pathlib import Path
from typing import Any, Optional
def sync_to_drive(
backup_path: Path,
source_dir: Path,
mode: str,
backup_note: str,
drive_sync_module: Any,
drive_sync_available: bool,
) -> bool:
"""Sync versioned backups to Google Drive.
Args:
backup_path: Path to backup directory to sync
source_dir: Source directory being backed up (for project name)
mode: Backup mode ('snapshot' or 'versioned')
backup_note: Optional note describing the backup
drive_sync_module: The GoogleDriveSync class (injected)
drive_sync_available: Whether google_drive_sync is importable
Returns:
bool: True if sync succeeded, False otherwise
"""
if not drive_sync_available or drive_sync_module is None:
return False
if mode != 'versioned':
return False
try:
project_name = "AIPass"
drive_sync = drive_sync_module()
if not drive_sync.authenticate():
return False
result = drive_sync.sync_backup_files(
backup_dir=backup_path,
project_name=project_name,
note=backup_note
)
return result["success"] if isinstance(result, dict) else result
except Exception:
return False
def set_backup_readonly(backup_path: Path) -> tuple[bool, str]:
"""Set backup directory to read-only for protection.
Applies read-only permissions recursively to all directories and files.
Args:
backup_path: Path to backup directory to protect
Returns:
Tuple of (success: bool, message: str)
"""
if not backup_path.exists():
return False, f"Backup path does not exist: {backup_path}"
try:
protected_dirs = 0
protected_files = 0
for root, dirs, files in os.walk(str(backup_path)):
try:
os.chmod(root, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH |
stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
protected_dirs += 1
except Exception:
pass
for file in files:
file_path = os.path.join(root, file)
try:
os.chmod(file_path, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
protected_files += 1
except Exception:
pass
return True, f"Protected {protected_dirs} dirs, {protected_files} files"
except Exception as e:
return False, f"Could not set read-only protection: {e}"
@@ -0,0 +1,84 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: path_builder.py - Backup path construction
# Date: 2025-11-18
# Version: 1.0.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted path construction logic for versioned mode
# * Handles long filename hashing
# * Mode-specific path building
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Path Builder - Backup destination path construction
Constructs destination paths based on backup mode and file structure.
"""
# =============================================
# IMPORTS
# =============================================
import hashlib
from pathlib import Path
# =============================================
# PATH CONSTRUCTION OPERATIONS
# =============================================
def build_backup_path(source_file: Path, source_dir: Path, backup_path: Path, mode: str) -> Path:
"""Construct backup destination path based on mode.
Args:
source_file: Source file path
source_dir: Source directory root
backup_path: Backup destination root
mode: Backup mode ('snapshot' or 'versioned')
Returns:
Full backup file path
"""
rel_path = source_file.relative_to(source_dir)
if mode == 'versioned':
# Versioned mode: file-based folders for version tracking
# Check if filename is too long (>50 chars) and needs shortening
if len(rel_path.name) > 50:
# Use shortened hash-based folder name for long filenames
name_hash = hashlib.md5(rel_path.name.encode()).hexdigest()[:8]
short_name = rel_path.name[:30] + f"_{name_hash}"
if str(rel_path.parent) == ".":
file_folder = backup_path / "root" / short_name
else:
file_folder = backup_path / rel_path.parent / short_name
return file_folder / rel_path.name
else:
# Normal path structure for shorter filenames
if str(rel_path.parent) == ".":
# Root-level file: AGENTS.md -> root/AGENTS.md/AGENTS.md
file_folder = backup_path / "root" / rel_path.name
return file_folder / rel_path.name
else:
# Subfolder file: backup.py -> backup_system/backup.py/backup.py
file_folder = backup_path / rel_path.parent / rel_path.name
return file_folder / rel_path.name
else:
# Snapshot and other modes: keep original flat structure
return backup_path / rel_path
# =============================================
# MODULE INITIALIZATION
# =============================================
# Pure handler - no initialization needed
@@ -0,0 +1,184 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: report_formatter.py - Backup result reporting
# Date: 2025-11-23
# Version: 1.1.0
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v1.1.0 (2025-11-23): Added dry-run statistics display
# * Added dry_run parameter to display_backup_results()
# * Shows "Would copy", "Would skip", "Would delete" in dry-run mode
# * Provides clear differentiation between dry-run and actual execution
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted statistics display formatting
# * Handles error/warning display
# * Formats skipped items reports
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
Report Formatter - Backup result formatting and display
Formats and displays backup operation results, statistics, errors, and warnings.
"""
# =============================================
# IMPORTS
# =============================================
import datetime
from pathlib import Path
from rich.console import Console
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
from ..models.backup_models import BackupResult
# =============================================
# REPORT FORMATTING OPERATIONS
# =============================================
def display_backup_results(result: BackupResult, mode_config: dict, backup_path: Path,
skipped_items: dict, filter_tracked_items_func, dry_run: bool = False) -> None:
"""Display comprehensive backup results with statistics and errors.
Args:
result: BackupResult with operation statistics
mode_config: Mode configuration dict
backup_path: Backup destination path
skipped_items: Dict of skipped directories and files
filter_tracked_items_func: Function to filter tracked items
dry_run: If True, show "would be" language in statistics
"""
duration = datetime.datetime.now() - result.start_time
# Determine overall result status
if result.critical_errors:
status_style = "[bold red]"
status_icon = "!"
status_text = "FAILED"
elif result.errors > 0:
status_style = "[bold red]"
status_icon = "X"
status_text = "COMPLETED WITH ERRORS"
elif result.warnings:
status_style = "[bold yellow]"
status_icon = "!"
status_text = "COMPLETED WITH WARNINGS"
else:
status_style = "[bold green]"
status_icon = "OK"
status_text = "COMPLETED SUCCESSFULLY"
# Use _header for main status
console.print()
_header(f"{status_icon} {mode_config['name'].upper()} {status_text}")
console.print()
# Statistics Section
console.print("[bold cyan]STATISTICS:[/bold cyan]")
console.print(f" Files checked: [dim]{result.files_checked}[/dim]")
if dry_run:
# Dry-run mode: show "would be" language
console.print(f" Would copy: [bold]{result.files_copied}[/bold] files")
console.print(f" Would skip: [dim]{result.files_skipped}[/dim] files (unchanged)")
if result.files_deleted > 0:
console.print(f" Would delete: [yellow]{result.files_deleted}[/yellow] files")
else:
# Normal mode: show actual actions
console.print(f" Files copied: [bold]{result.files_copied}[/bold]")
if result.mode == 'versioned' and result.files_added > 0:
console.print(f" Files added: [green]{result.files_added}[/green] (new)")
console.print(f" Files skipped: [dim]{result.files_skipped}[/dim]")
if result.mode == 'versioned' and result.files_deleted == 0:
console.print(f" Files deleted: [dim]{result.files_deleted}[/dim] [dim](cleanup disabled - keeps history)[/dim]")
else:
console.print(f" Files deleted: [red]{result.files_deleted}[/red]")
console.print(f" Errors: {status_style}{result.errors}[/]")
console.print(f" Warnings: [yellow]{len(result.warnings)}[/yellow]")
console.print(f" Duration: [blue]{duration.total_seconds():.2f}s[/blue]")
console.print(f" Location: [dim]{backup_path}[/dim]")
# Display detailed error information
if result.critical_errors:
console.print()
console.print(f"[bold red]CRITICAL ERRORS ({len(result.critical_errors)}):[/bold red]")
console.print("-" * 40)
for i, error in enumerate(result.critical_errors, 1):
console.print(f" {i}. [red]{error}[/red]")
console.print()
console.print("[bold]RECOVERY SUGGESTIONS:[/bold]")
console.print(" - Check disk space and permissions")
console.print(" - Ensure backup destination is accessible")
console.print(" - Try running as administrator if permission issues")
console.print(" - Check if antivirus is blocking file operations")
elif result.error_details:
console.print()
console.print(f"[bold red]ERRORS ({len(result.error_details)}):[/bold red]")
console.print("-" * 40)
for i, error in enumerate(result.error_details[:10], 1):
console.print(f" {i}. [red]{error}[/red]")
if len(result.error_details) > 10:
console.print(f" [dim]... and {len(result.error_details) - 10} more errors[/dim]")
console.print()
console.print("[bold]SUGGESTIONS:[/bold]")
console.print(" - Some files may be in use - try closing applications")
console.print(" - Check file permissions on failed files")
if result.warnings:
console.print()
console.print(f"[bold yellow]WARNINGS ({len(result.warnings)}):[/bold yellow]")
console.print("-" * 40)
for i, warning in enumerate(result.warnings[:5], 1):
console.print(f" {i}. [yellow]{warning}[/yellow]")
if len(result.warnings) > 5:
console.print(f" [dim]... and {len(result.warnings) - 5} more warnings[/dim]")
# Display project-specific skipped items
tracked_items = filter_tracked_items_func(skipped_items)
total_tracked = len(tracked_items["directories"]) + len(tracked_items["files"])
total_all_skipped = len(skipped_items["directories"]) + len(skipped_items["files"])
if total_tracked > 0:
console.print()
console.print(f"[bold cyan]NOTABLE SKIPPED ITEMS ({total_tracked}):[/bold cyan]")
console.print(f"[dim]Total ignored: {total_all_skipped}[/dim]")
console.print("-" * 50)
if tracked_items["directories"]:
console.print(f"[bold]Directories ({len(tracked_items['directories'])}):[/bold]")
for i, dir_path in enumerate(sorted(tracked_items["directories"]), 1):
console.print(f" {i}. [dim]{dir_path}/[/dim]")
if tracked_items["files"]:
console.print(f"[bold]Files ({len(tracked_items['files'])}):[/bold]")
for i, file_path in enumerate(sorted(tracked_items["files"]), 1):
console.print(f" {i}. [dim]{file_path}[/dim]")
else:
console.print()
if total_all_skipped > 0:
console.print(f"[dim]No project-specific items skipped ({total_all_skipped} common items filtered)[/dim]")
else:
console.print("[dim]No items were skipped.[/dim]")
# =============================================
# MODULE INITIALIZATION
# =============================================
# Pure handler - no initialization needed
View File
@@ -0,0 +1,98 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: backup_timestamps.py - Tracks last-run timestamps for all backup modes
# Date: 2026-02-22
# Version: 1.0.0
# Category: handlers/utils
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-22): Initial version - show/update backup timestamps
#
# CODE STANDARDS:
# - Handler utility: no console output, returns data for module layer
# - JSON file at backups/backup_timestamps.json
# =============================================
"""
Backup Timestamps - Tracks when each backup mode was last run.
Provides get/update functions for a shared timestamps file so any
backup command can display how fresh each backup type is.
"""
import json
from pathlib import Path
from datetime import datetime
_BACKUP_ROOT = Path(__file__).resolve().parents[4] # src/aipass/backup/
TIMESTAMPS_FILE = _BACKUP_ROOT / "backup_data" / "backup_timestamps.json"
MODES = ["snapshot", "versioned", "drive_sync"]
def get_timestamps() -> dict:
"""Read all backup timestamps from disk.
Returns:
dict with mode keys and ISO datetime string values (or None if never run)
"""
data = {}
if TIMESTAMPS_FILE.exists():
try:
data = json.loads(TIMESTAMPS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
data = {}
return {mode: data.get(mode) for mode in MODES}
def update_timestamp(mode: str) -> None:
"""Update the timestamp for a backup mode to now.
Args:
mode: One of 'snapshot', 'versioned', 'drive_sync'
"""
data = {}
if TIMESTAMPS_FILE.exists():
try:
data = json.loads(TIMESTAMPS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
data = {}
data[mode] = datetime.now().isoformat()
TIMESTAMPS_FILE.parent.mkdir(parents=True, exist_ok=True)
TIMESTAMPS_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def format_age(iso_str: str | None) -> str:
"""Format an ISO timestamp as a human-readable age string.
Args:
iso_str: ISO datetime string, or None if never run
Returns:
String like '2 days ago', '3 hours ago', 'just now', or 'never'
"""
if not iso_str:
return "never"
try:
then = datetime.fromisoformat(iso_str)
except (ValueError, TypeError):
return "unknown"
delta = datetime.now() - then
seconds = int(delta.total_seconds())
if seconds < 60:
return "just now"
elif seconds < 3600:
mins = seconds // 60
return f"{mins} min{'s' if mins != 1 else ''} ago"
elif seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''} ago"
else:
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''} ago"
@@ -0,0 +1,87 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: reauth_handler.py - Google Drive Re-Authentication Handler
# Date: 2026-02-20
# Version: 1.0.0
# Category: backup_system/handlers/utils
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Extracted from reauth_drive.py module
# * Moved reauth() implementation to handler layer
# * Follows seed 3-layer architecture standards
#
# CODE STANDARDS:
# - Handlers must be independent and transportable
# - No prax imports (handler tier)
# - No cross-handler imports except within same domain
# =============================================
"""
Google Drive Re-Authentication Handler
Implementation logic for re-authenticating with Google Drive via console OAuth flow.
Called by the reauth_drive module orchestrator.
"""
from pathlib import Path
SCOPES = ['https://www.googleapis.com/auth/drive.file']
CREDS_PATH = Path.home() / '.aipass' / 'drive_creds.json'
def reauth(client_secrets_path: Path) -> bool:
"""Perform Google Drive re-authentication via console OAuth flow.
Args:
client_secrets_path: Path to the OAuth client secrets JSON file
Returns:
bool: True if authentication succeeded, False otherwise
"""
try:
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
except ImportError as e:
return False
# Step 1: Try refreshing existing token first
if CREDS_PATH.exists():
try:
creds = Credentials.from_authorized_user_file(str(CREDS_PATH), SCOPES)
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
with open(CREDS_PATH, 'w', encoding='utf-8') as f:
f.write(creds.to_json())
# Test connection
service = build('drive', 'v3', credentials=creds)
about = service.about().get(fields="user").execute()
return True
except Exception:
pass # Proceed with full re-authentication
# Step 2: Full OAuth flow via console
if not client_secrets_path.exists():
return False
flow = InstalledAppFlow.from_client_secrets_file(str(client_secrets_path), SCOPES)
try:
creds = flow.run_local_server(port=8085, open_browser=False)
except Exception:
return False
# Save new credentials
CREDS_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(CREDS_PATH, 'w', encoding='utf-8') as f:
f.write(creds.to_json())
# Test connection
try:
service = build('drive', 'v3', credentials=creds)
about = service.about().get(fields="user,storageQuota").execute()
return True
except Exception:
return False
+228
View File
@@ -0,0 +1,228 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: system_utils.py - Platform-aware file and console utilities
# Date: 2025-11-23
# Version: 2.1.1
# Category: handlers
#
# CHANGELOG (Max 5 entries):
# - v2.1.1 (2025-11-23): CRITICAL FIX - Switch to plain print() for clickability
# * Replaced console.print() with plain print() in safe_print()
# * Rich console truncates at 80 chars breaking long paths
# * Plain print() preserves full paths for terminal Ctrl+click
# - v2.1.0 (2025-11-23): Attempted fix with no_wrap parameter (insufficient)
# * Added no_wrap=True to console.print() but Rich still truncates
# * Identified Rich console 80-char limit as root cause
# - v2.0.0 (2025-11-16): Extracted to seed standards
# * Applied seed standards formatting and meta header
# * Preserved all logic from backup_utils v1.0.0
# - v1.1.0 (2025-11-22): Updated to seed Rich formatting standards
# - v1.0.0 (2025-11-18): Extracted from backup_core.py
# * Extracted system utilities
# * Cross-platform file operations
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Handlers must be independent and transportable
# - No cross-handler imports except within same domain
# =============================================
"""
System Utilities
Platform-aware helper functions and context managers for file operations and console output.
Provides critical utilities for read-only file handling on Linux and emoji-safe printing
across platforms.
Key Functions:
- temporarily_writable(): Context manager for safe read-only file modification
- safe_print(): Emoji-aware console output with fallback support
- EMOJI_SUPPORT: Platform-specific emoji capability constant
"""
# =============================================
# IMPORTS
# =============================================
import sys
import os
import stat
import logging
from pathlib import Path
from contextlib import contextmanager
logger = logging.getLogger(__name__)
# =============================================
# CONSTANTS
# =============================================
# Fix console encoding for emojis (Linux/Windows compatibility)
EMOJI_SUPPORT = True
if sys.platform == 'win32':
try:
# Try to enable UTF-8 output on Windows
os.system('chcp 65001 > nul')
# Use getattr to safely call reconfigure if it exists
reconfigure_stdout = getattr(sys.stdout, 'reconfigure', None)
if reconfigure_stdout:
reconfigure_stdout(encoding='utf-8', errors='replace')
reconfigure_stderr = getattr(sys.stderr, 'reconfigure', None)
if reconfigure_stderr:
reconfigure_stderr(encoding='utf-8', errors='replace')
except Exception:
EMOJI_SUPPORT = False
# =============================================
# CONTEXT MANAGERS
# =============================================
@contextmanager
def temporarily_writable(path):
"""Context manager to temporarily make a directory/file writable on Linux.
This is critical for Linux because it strictly enforces read-only permissions,
unlike Windows which allows owners to bypass them.
Used in 10+ locations throughout backup operations for:
- Creating new directories in read-only backup roots
- Updating files in protected backup directories
- Deleting files from read-only locations
Args:
path: Path object or string path to make temporarily writable
Yields:
Path object with write permissions temporarily enabled
Example:
with temporarily_writable(backup_path):
# Perform operations that need write access
shutil.copy2(source, target)
# Permissions automatically restored here
"""
path_obj = Path(path)
original_mode = None
try:
# Store original permissions if path exists
if path_obj.exists():
original_mode = path_obj.stat().st_mode
# Make writable for owner
if path_obj.is_dir():
# Directory: add write and execute permissions for owner
path_obj.chmod(original_mode | stat.S_IWUSR | stat.S_IXUSR)
else:
# File: add write permission for owner
path_obj.chmod(original_mode | stat.S_IWUSR)
yield path_obj
finally:
# Restore original permissions if we changed them
if original_mode is not None and path_obj.exists():
try:
path_obj.chmod(original_mode)
except Exception:
pass
# =============================================
# FILESYSTEM OPERATIONS
# =============================================
def ensure_backup_directory(backup_dest: Path, backup_path: Path, is_dynamic: bool) -> tuple[bool, str | None]:
"""Create backup directory if needed with proper permission handling.
Args:
backup_dest: Root backup destination path
backup_path: Specific backup folder path
is_dynamic: True if dynamic mode (needs special handling)
Returns:
Tuple of (success: bool, error_message: str or None)
"""
try:
# Check if backup_dest exists and might be read-only
if backup_dest.exists():
# Use context manager to temporarily make parent writable
with temporarily_writable(backup_dest.parent):
with temporarily_writable(backup_dest):
backup_dest.mkdir(parents=True, exist_ok=True)
else:
# Create normally if it doesn't exist
backup_dest.mkdir(parents=True, exist_ok=True)
if is_dynamic:
if backup_path.exists():
with temporarily_writable(backup_path.parent):
with temporarily_writable(backup_path):
backup_path.mkdir(parents=True, exist_ok=True)
else:
backup_path.mkdir(parents=True, exist_ok=True)
return True, None
except PermissionError as e:
return False, f"Permission denied creating backup directory {backup_dest}: {e}"
except OSError as e:
return False, f"OS error creating backup directory {backup_dest}: {e}"
except Exception as e:
return False, f"Unexpected error creating backup directory {backup_dest}: {e}"
def remove_empty_dirs(path: Path):
"""Remove empty directories recursively.
Args:
path: Root path to clean empty directories from
"""
try:
for item in path.iterdir():
if item.is_dir():
remove_empty_dirs(item)
try:
item.rmdir()
except OSError:
pass
except Exception:
pass
# =============================================
# HELPER FUNCTIONS
# =============================================
def safe_print(text):
"""Print text with emoji fallback for systems that don't support them.
Handles console encoding issues across different platforms.
Args:
text: String to print (may contain emoji characters)
Returns:
None (prints to stdout)
Example:
safe_print("Backup complete!")
"""
if not EMOJI_SUPPORT:
# Replace emojis with text equivalents
text = text.replace('!', '[CRITICAL]')
text = text.replace('X', '[ERROR]')
text = text.replace('!', '[WARNING]')
text = text.replace('OK', '[SUCCESS]')
try:
# Use plain print() to avoid Rich console truncation at 80 chars
# This preserves full paths for terminal clickability
logger.info(text)
except UnicodeEncodeError:
# Final fallback - strip all non-ASCII characters
safe_text = text.encode('ascii', errors='replace').decode('ascii')
logger.info(safe_text)
# =============================================
# MODULE INITIALIZATION
# =============================================
# No initialization needed - pure utility functions
@@ -0,0 +1 @@
# JSON Templates package - Default JSON file templates
+1
View File
@@ -0,0 +1 @@
# Modules package - Branch-specific functionality modules
+567
View File
@@ -0,0 +1,567 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: backup_core.py - Main backup system orchestration module
# Date: 2025-11-23
# Version: 2.1.0
# Category: backup_system
#
# CHANGELOG (Max 5 entries):
# - v2.1.0 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang, sys.path manipulation, prax/cli imports
# * Uses standard logging and rich console
# * Relative handler imports
# * JSON_DIR uses path relative to __file__
# - v2.0.4 (2025-11-23): CRITICAL FIX - Versioned dry-run accuracy
# - v2.0.3 (2025-11-23): Enhanced dry-run with accurate statistics
# - v2.0.2 (2025-11-23): CRITICAL FIX - Dry-run now shows proposed changes
# - v2.0.1 (2025-11-23): CRITICAL BUG FIX - Dry-run statistics counter
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Orchestrate workflows, delegate to handlers
# - Import handlers, never implement business logic
# =============================================
"""
Backup System Core Module
Main entry point for backup functionality following seed architecture standards.
Provides CLI command routing and BackupEngine orchestration.
This module coordinates the complete backup workflow by bringing together:
- Configuration (BACKUP_MODES, GLOBAL_IGNORE_PATTERNS)
- Models (BackupResult)
- File operations (copy_file_with_structure, copy_versioned_file)
- Utilities (safe_print, temporarily_writable, system logging)
- JSON tracking (log_operation)
Architecture Pattern:
- handle_command(args) - CLI entry point for backup commands
- BackupEngine class - Main orchestrator (delegates to handlers)
- Follows seed 3-layer architecture (cli -> modules -> handlers)
"""
# =============================================
# IMPORTS
# =============================================
# Infrastructure
import sys
import datetime
import logging
from pathlib import Path
from typing import Dict
from rich.console import Console
logger = logging.getLogger(__name__)
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
# Import handlers (core dependencies) - relative imports
from ..handlers.config.config_handler import (
BACKUP_MODES,
GLOBAL_IGNORE_PATTERNS,
IGNORE_EXCEPTIONS,
filter_tracked_items,
should_ignore
)
from ..handlers.models.backup_models import BackupResult
from ..handlers.operations.file_operations import copy_file_with_structure, copy_versioned_file
from ..handlers.utils.system_utils import safe_print
from ..handlers.json.json_handler import log_operation, ensure_module_jsons
from ..handlers.json.changelog_handler import (
load_changelog as load_changelog_file,
save_changelog_entry as save_changelog_entry_file,
display_previous_comments as display_previous_comments_file
)
from ..handlers.json.backup_info_handler import (
load_backup_info as load_backup_info_file,
save_backup_info as save_backup_info_file
)
# Integration stubs and JSON setup moved to handlers
# sync_to_drive → handlers/integrations/ (when implemented)
# set_backup_readonly → handlers/integrations/ (when implemented)
# initialize_json_files → called directly in __init__
_BACKUP_ROOT = Path(__file__).resolve().parents[2] # src/aipass/backup/
JSON_DIR = _BACKUP_ROOT / "backup_json"
# =============================================
# INTROSPECTION (SEED PATTERN)
# =============================================
def print_introspection():
"""Display module info and connected handlers"""
from pathlib import Path
console.print()
console.print("[bold cyan]Backup Core Module[/bold cyan]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print()
# Show handler domains this module uses
handlers_base = Path(__file__).parent.parent / "handlers"
handler_domains = [
"config",
"models",
"operations",
"utils",
"json",
"reporting"
]
for domain in handler_domains:
domain_path = handlers_base / domain
if domain_path.exists():
console.print(f" [cyan]handlers/{domain}/[/cyan]")
console.print()
console.print("[dim]Run 'python3 backup_core.py --help' for usage[/dim]")
console.print()
# =============================================
# MODULE-LEVEL COMMAND HANDLER
# =============================================
def print_help():
"""Display help for the backup_core module."""
console.print()
console.print("[bold cyan]Backup Core Module[/bold cyan]")
console.print()
console.print("Main backup orchestration module")
console.print()
console.print("[yellow]Usage:[/yellow]")
console.print(" python3 backup_core.py # Show module info")
console.print(" python3 backup_core.py --help # Show this help")
console.print()
console.print("[yellow]Commands:[/yellow]")
console.print(" snapshot Full copy backup (overwrites destination)")
console.print(" versioned Timestamped backup with incremental copies")
console.print(" all Full backup cycle (snapshot + versioned + drive-sync)")
console.print()
def handle_command(args) -> bool:
"""Route backup commands to appropriate handler.
This is the module-level entry point for CLI commands related to backup.
Checks if the command matches backup commands and routes accordingly.
Args:
args: Command-line arguments from CLI parser
Returns:
bool: True if command was handled, False if not a backup command
Example:
result = handle_command(args)
if result:
console.print(f"Backup completed")
"""
# Check if args has backup-related command
if not hasattr(args, 'command'):
return False
# Check for backup commands ('all' handled by entry point, listed here for drone discovery)
if args.command not in ['snapshot', 'versioned', 'all']:
return False
# 'all' is orchestrated by backup_system.py entry point (snapshot → versioned → drive-sync)
if args.command == 'all':
return False
# Set mode directly from command
mode = args.command
# Check for dry-run flag
dry_run = getattr(args, 'dry_run', False)
# Get backup note if provided
backup_note = getattr(args, 'note', 'No note provided')
try:
# Create engine and run backup
engine = BackupEngine(mode, dry_run=dry_run)
result = engine.run_backup(backup_note)
# Return True to indicate command was handled
return True
except Exception as e:
logger.error(f"[backup_core] Backup command failed: {e}")
console.print(f"[ERROR] Backup failed: {e}")
return True # Still handled, just failed
# =============================================
# BACKUP ENGINE CLASS
# =============================================
class BackupEngine:
"""Main backup system orchestrator (seed-compliant wrapper).
Provides the core backup workflow coordination following seed architecture.
Delegates heavy lifting to handler modules rather than implementing logic here.
This class is a thin orchestration layer that:
- Initializes with backup mode and configuration
- Coordinates file scanning and copying via handlers
- Manages backup metadata persistence
- Reports statistics and status
Attributes:
mode (str): Backup mode (e.g., 'snapshot', 'versioned')
dry_run (bool): If True, scan files without copying
mode_config (dict): Configuration for selected mode
backup_path (Path): Destination path for backup
source_dir (Path): Source directory to backup
Raises:
ValueError: If mode is invalid or configuration missing
"""
def __init__(self, mode: str, dry_run: bool = False):
"""Initialize backup engine with specified mode.
Validates mode, loads configuration, and prepares for backup operation.
Args:
mode: Backup mode from BACKUP_MODES ('snapshot' or 'versioned')
dry_run: If True, scan files without copying (test ignore patterns)
Raises:
ValueError: If mode is invalid
KeyError: If mode configuration is missing
Example:
engine = BackupEngine('versioned', dry_run=True)
result = engine.run_backup("Before refactor")
"""
if mode not in BACKUP_MODES:
raise ValueError(f"Invalid backup mode: {mode}. Valid modes: {list(BACKUP_MODES.keys())}")
self.mode = mode
self.dry_run = dry_run
self.mode_config = BACKUP_MODES[mode]
# Auto-detect source directory
self.source_dir = Path.home()
self.backup_dest = Path(self.mode_config['destination'])
self.ignore_patterns = GLOBAL_IGNORE_PATTERNS
# Mode-specific paths - all modes use fixed folder names
self.backup_folder_name = self.mode_config['folder_name']
self.backup_path = self.backup_dest / self.backup_folder_name
# Initialize JSON system (handler creates directory if needed)
ensure_module_jsons("backup_core")
# JSON files (mode-specific) - backup-specific tracking files
self.backup_info_file = JSON_DIR / f"{mode}_backup.json"
self.changelog_file = JSON_DIR / f"{mode}_backup_changelog.json"
self.restore_log_file = JSON_DIR / f"{mode}_restore_history.json"
# Display clear mode identification
console.print(f"Mode: {self.mode_config['name']}")
console.print(f"Source: {self.source_dir}")
console.print(f"Destination: {self.backup_dest}")
console.print(f"Usage: {self.mode_config['usage']}")
logger.info(f"[backup_core] Initialized {mode} mode - source: {self.source_dir}")
# =============================================
# UTILITY METHODS
# =============================================
def should_ignore(self, path: Path) -> bool:
"""Check if a file/folder should be ignored based on patterns.
Delegates to handler function for centralized pattern matching.
Args:
path: Path to check
Returns:
True if path should be ignored, False otherwise
"""
return should_ignore(path, self.ignore_patterns, IGNORE_EXCEPTIONS, self.backup_dest)
def ensure_backup_directory(self, result: BackupResult) -> bool:
"""Create backup directory if needed (delegates to handler)."""
from ..handlers.utils.system_utils import ensure_backup_directory as ensure_dir
success, error_msg = ensure_dir(self.backup_dest, self.backup_path, self.mode_config['behavior'] == 'dynamic')
if not success:
error_text = error_msg if error_msg is not None else "Unknown error creating backup directory"
result.add_error(error_text, is_critical=True)
safe_print(f"CRITICAL: {error_text}")
logger.error(f"[backup_core] {error_text}")
return success
def file_needs_backup(self, source_file: Path, backup_file: Path, last_timestamps: dict) -> bool:
"""Check if file needs backup (delegates to handler)."""
from ..handlers.operations.file_operations import file_needs_backup as check_file
return check_file(source_file, backup_file, last_timestamps, self.source_dir)
def remove_empty_dirs(self, path: Path):
"""Remove empty directories (delegates to handler)."""
from ..handlers.utils.system_utils import remove_empty_dirs as clean_dirs
clean_dirs(path)
# Changelog and backup info operations (thin delegators to handlers)
def load_changelog(self) -> Dict:
"""Load changelog data from JSON file."""
return load_changelog_file(self.changelog_file)
def save_changelog_entry(self, note: str) -> bool:
"""Save a changelog entry for the current backup operation."""
return save_changelog_entry_file(self.changelog_file, note, self.mode, self.backup_path)
def display_previous_comments(self):
"""Display previous changelog comments for the current backup mode."""
display_previous_comments_file(self.changelog_file, self.mode_config['name'])
def load_backup_info(self) -> Dict:
"""Load backup info metadata from JSON file."""
return load_backup_info_file(self.backup_info_file, self.mode_config['behavior'])
def save_backup_info(self, backup_info: Dict) -> bool:
"""Save backup info metadata to JSON file."""
return save_backup_info_file(self.backup_info_file, backup_info)
# =============================================
# MAIN BACKUP EXECUTION
# =============================================
def run_backup(self, backup_note: str = "No note provided") -> BackupResult:
"""Execute backup - thin orchestration layer calling handlers.
Coordinates backup workflow by delegating to specialized handlers.
Args:
backup_note: User note describing backup purpose/context
Returns:
BackupResult: Complete operation result with statistics and status
"""
logger.info(f"[backup_core] Starting {self.mode} backup: {backup_note}")
# Show last backup timestamps for all modes
from ..handlers.utils.backup_timestamps import get_timestamps, format_age
ts = get_timestamps()
console.print()
console.print("[dim]Last backups:[/dim]")
console.print(f" [dim]Snapshot: {format_age(ts.get('snapshot'))}[/dim]")
console.print(f" [dim]Versioned: {format_age(ts.get('versioned'))}[/dim]")
console.print(f" [dim]Drive sync: {format_age(ts.get('drive_sync'))}[/dim]")
result = BackupResult()
result.mode = self.mode
result.backup_path = str(self.backup_path)
# Display dry-run warning if in test mode
if self.dry_run:
console.print()
console.print("╭" + "─" * 68 + "╮", style="yellow")
console.print("│" + " " * 18 + "[bold yellow]🔍 DRY-RUN MODE ACTIVE[/bold yellow]" + " " * 18 + "│", style="yellow")
console.print("│" + " " * 10 + "[yellow]Files will be scanned but NOT copied or deleted[/yellow]" + " " * 10 + "│", style="yellow")
console.print("╰" + "─" * 68 + "╯", style="yellow")
console.print()
_header(f"AIPass {self.mode_config['name']} - {self.mode_config['description']}")
console.print()
# Ensure backup directory exists
if not self.ensure_backup_directory(result):
console.print(f"\nBACKUP FAILED: Could not create backup directory")
return result
# Load previous backup info
backup_info = self.load_backup_info()
last_timestamps = backup_info.get("file_timestamps", {}) if self.mode_config['behavior'] == 'dynamic' else {}
if not isinstance(last_timestamps, dict):
last_timestamps = {}
# HANDLER: Scan files
from ..handlers.operations.file_scanner import scan_files
files_to_backup, skipped_items = scan_files(self.source_dir, self.should_ignore)
# HANDLER: Process files
from ..handlers.operations.path_builder import build_backup_path
current_timestamps = {}
total_files = len(files_to_backup)
from rich.progress import Progress, BarColumn, TextColumn, TimeRemainingColumn
# Progress tracking with Rich
with Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TimeRemainingColumn(),
console=console,
transient=True
) as progress:
task = progress.add_task("Processing files...", total=total_files)
for idx, file_path in enumerate(files_to_backup):
result.files_checked += 1
# Build backup path
backup_file = build_backup_path(file_path, self.source_dir, self.backup_path, self.mode)
current_timestamps[str(file_path.relative_to(self.source_dir))] = file_path.stat().st_mtime
# Process file
try:
if self.dry_run:
# Show what WOULD happen in dry-run mode
is_new = not backup_file.exists()
if self.mode_config['behavior'] == 'versioned':
# Versioned mode: check if file actually changed (mtime comparison)
if is_new:
safe_print(f"📄 Would copy (new): {file_path}")
result.files_copied += 1
elif backup_file.exists():
# Check if file changed by comparing mtimes
source_mtime = file_path.stat().st_mtime
target_mtime = backup_file.stat().st_mtime
if source_mtime != target_mtime:
safe_print(f"📄 Would copy (updated): {file_path}")
result.files_copied += 1
else:
result.files_skipped += 1 # Unchanged, would skip
else:
# Snapshot mode: use timestamp-based check
if is_new:
safe_print(f"📄 Would copy (new): {file_path}")
result.files_copied += 1
elif self.file_needs_backup(file_path, backup_file, last_timestamps):
safe_print(f"📄 Would copy (updated): {file_path}")
result.files_copied += 1
else:
result.files_skipped += 1 # Only skip if unchanged
elif self.mode_config['behavior'] == 'versioned':
if copy_versioned_file(file_path, backup_file, self.backup_path, result):
result.files_copied += 1
elif self.file_needs_backup(file_path, backup_file, last_timestamps):
if copy_file_with_structure(file_path, backup_file, self.backup_path, result):
result.files_copied += 1
else:
result.files_skipped += 1
except Exception as e:
result.add_error(f"Error processing {file_path}: {e}", is_critical=True)
# Use console.print directly for critical errors so they appear above progress bar
console.print(f"[red]CRITICAL: Error processing {file_path}: {e}[/red]")
# Update progress
progress.advance(task)
console.print(f"Processing completed: {total_files}/{total_files} files checked")
# HANDLER: Cleanup deleted files (dynamic mode only)
# RE-ENABLED 2025-11-23: Fixed to respect IGNORE_EXCEPTIONS in third pass
# Now runs in dry-run mode to show what WOULD be deleted
if self.backup_path.exists() and self.mode_config['behavior'] == 'dynamic':
from ..handlers.operations.file_cleanup import cleanup_deleted_files
cleanup_deleted_files(self.backup_path, self.source_dir, self.should_ignore, result, self.dry_run)
# HANDLER: Remove empty directories (handled by cleanup_deleted_files now)
# self.remove_empty_dirs(self.backup_path)
# HANDLER: Create and save backup metadata
from ..handlers.json.backup_metadata_builder import create_backup_metadata
backup_info = create_backup_metadata(
self.mode, self.mode_config['behavior'], backup_note, self.backup_folder_name,
self.backup_path, self.source_dir, result, current_timestamps, backup_info
)
self.save_backup_info(backup_info)
# HANDLER: Display backup results
from ..handlers.reporting.report_formatter import display_backup_results
display_backup_results(result, self.mode_config, self.backup_path, skipped_items, filter_tracked_items, self.dry_run)
# Update JSON system
execution_time = int((datetime.datetime.now() - result.start_time).total_seconds() * 1000)
if result.success and result.errors == 0:
log_operation(
"backup",
{
"mode": self.mode,
"files_copied": result.files_copied,
"success": True,
"execution_time_ms": execution_time
},
module_name="backup_core"
)
logger.info(f"[backup_core] {self.mode} backup completed successfully - {result.files_copied} files copied in {execution_time}ms")
# Update backup timestamp and show confirmation
from ..handlers.utils.backup_timestamps import update_timestamp, get_timestamps, format_age
update_timestamp(self.mode)
ts = get_timestamps()
console.print()
console.print(f"[dim]Backups now:[/dim]")
console.print(f" [dim]Snapshot: {format_age(ts.get('snapshot'))}[/dim]")
console.print(f" [dim]Versioned: {format_age(ts.get('versioned'))}[/dim]")
console.print(f" [dim]Drive sync: {format_age(ts.get('drive_sync'))}[/dim]")
# Integration hooks (stubs - implement in handlers/integrations/)
if self.mode == 'versioned':
logger.info("[backup_core] Drive sync skipped (not implemented)")
logger.info("[backup_core] Read-only protection skipped (not implemented)")
else:
log_operation(
"backup",
{
"mode": self.mode,
"errors": result.errors,
"success": False,
"execution_time_ms": execution_time
},
module_name="backup_core"
)
logger.error(f"[backup_core] {self.mode} backup completed with {result.errors} errors in {execution_time}ms")
from ..handlers.json.statistics_handler import update_data_file
update_data_file(result)
return result
# =============================================
# MODULE INITIALIZATION
# =============================================
# =============================================
# STANDALONE ENTRY POINT (SEED PATTERN)
# =============================================
if __name__ == "__main__":
import sys
# Show introspection when run without arguments
if len(sys.argv) == 1:
print_introspection()
sys.exit(0)
# Handle help flag
if sys.argv[1] in ['--help', '-h', 'help']:
print_help()
sys.exit(0)
console.print("[yellow]Note:[/yellow] Run via backup_system.py entry point for full functionality")
console.print()
print_introspection()
@@ -0,0 +1,553 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: google_drive_sync.py - Google Drive Integration for AIPass Backup System
# Date: 2025-10-30
# Version: 2.6.0
# Category: backup_system/modules
#
# CHANGELOG (Max 5 entries - remove oldest when adding new):
# - v2.6.0 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang, sys.path manipulation, prax/cli imports
# * Uses standard logging and rich console
# * Relative handler imports, Google API deps wrapped in try/except
# - v2.5.0 (2026-02-22): Rich progress bar for drive-sync uploads, per-file progress callback
# - v2.4.0 (2026-02-21): Two-phase sync (prepare+upload), CLI visibility, dedup fix, --test mode
# - v2.3.2 (2026-02-10): Fixed deepcopy RuntimeError in _save_data during concurrent uploads
# - v2.2.0 (2026-02-10): Fixed SSL concurrency bug - per-thread credentials, retry with backoff, folder cache lock
#
# CODE STANDARDS:
# - Module orchestrates, handlers implement
# - CLI display (console.print) belongs here, not in handlers
# - Google API calls delegated to drive_sync_client handler
# =============================================
"""
Google Drive Sync Module - Orchestrates Drive backup operations.
Routes drive-sync commands, displays CLI output, delegates to
drive_sync_client handler for API operations.
"""
# =============================================
# IMPORTS
# =============================================
import sys
import logging
from pathlib import Path
from datetime import datetime
from rich.console import Console
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
logger = logging.getLogger(__name__)
console = Console()
_BACKUP_ROOT = Path(__file__).resolve().parents[2] # src/aipass/backup/
# =============================================
# CONSTANTS & CONFIG
# =============================================
# OAuth scopes for Drive access
SCOPES = ['https://www.googleapis.com/auth/drive.file']
# Module configuration
MODULE_NAME = "google_drive_sync"
JSON_DIR = _BACKUP_ROOT / "backup_json"
CONFIG_FILE = JSON_DIR / f"{MODULE_NAME}_config.json"
DATA_FILE = JSON_DIR / f"{MODULE_NAME}_data.json"
LOG_FILE = JSON_DIR / f"{MODULE_NAME}_log.json"
# =============================================
# CLIENT CLASS (delegated to handler)
# =============================================
try:
from ..handlers.operations.drive_sync_client import (
GoogleDriveSync,
)
from ..handlers.json.drive_sync_json import (
load_config as _load_config_fn,
load_data as _load_data_fn,
)
DRIVE_AVAILABLE = True
except ImportError:
GoogleDriveSync = None # type: ignore
_load_config_fn = None # type: ignore
_load_data_fn = None # type: ignore
DRIVE_AVAILABLE = False
logger.info("[google_drive_sync] Google Drive dependencies not available")
def _load_config():
"""Load config using module JSON paths."""
return _load_config_fn(CONFIG_FILE)
def _load_data():
"""Load data using module JSON paths."""
return _load_data_fn(DATA_FILE)
# =============================================
# BUSINESS OPERATIONS (delegated to handlers)
# =============================================
try:
from ..handlers.operations.drive_sync_ops import (
clear_file_tracker as _clear_file_tracker_handler,
get_file_tracker_stats,
test_drive_connection as _test_drive_connection,
)
except ImportError:
_clear_file_tracker_handler = None # type: ignore
get_file_tracker_stats = None # type: ignore
_test_drive_connection = None # type: ignore
def _show_file_tracker_stats() -> bool:
"""Display file tracker statistics."""
try:
stats = get_file_tracker_stats()
console.print(f"File Tracker Statistics:")
console.print(f" - Total tracked files: {stats['total']}")
if stats['sample']:
console.print(f" - Sample entries:")
for i, entry in enumerate(stats['sample']):
console.print(f" {i+1}. {entry['file']} (last sync: {entry['last_sync']})")
if stats['truncated']:
remaining = stats['total'] - len(stats['sample'])
console.print(f" ... and {remaining} more files")
return True
except Exception as e:
console.print(f"Error showing tracker stats: {e}")
logger.error(f"Error showing tracker stats: {e}")
return False
def _clear_file_tracker() -> bool:
"""Clear the file tracker cache for fresh sync."""
try:
data = _load_data()
tracker_count = len(data.get("runtime_state", {}).get("file_tracker", {}))
success = _clear_file_tracker_handler()
if success:
console.print(f"Cleared {tracker_count} entries from file tracker")
logger.info(f"Cleared {tracker_count} entries from file tracker")
else:
console.print("File tracker already empty or clear failed")
return success
except Exception as e:
console.print(f"Error clearing file tracker: {e}")
logger.error(f"Error clearing file tracker: {e}")
return False
def _test_drive_sync() -> bool:
"""Test Drive integration."""
try:
sync = GoogleDriveSync()
if not sync.authenticate():
return False
console.print("Testing folder creation...")
result = _test_drive_connection(sync)
if result:
folder_id = sync.get_or_create_backup_folder()
console.print(f"Backup folder ready: {folder_id}")
else:
console.print("Failed to create backup folder")
return result
except Exception as e:
console.print(f"Test failed: {e}")
logger.error(f"Test failed: {e}")
return False
def _run_sync_test() -> bool:
"""Run a small test sync to verify Drive integration."""
import shutil
console.print("[bold cyan]Drive Sync Test[/bold cyan]")
console.print()
# Create test files
test_dir = _BACKUP_ROOT / "backups" / "_sync_test"
test_dir.mkdir(parents=True, exist_ok=True)
test_files = {
"test_file_1.txt": "Hello from AIPass backup test",
"test_file_2.json": '{"test": true, "timestamp": "' + datetime.now().isoformat() + '"}',
"subdir/nested_file.txt": "Nested directory test",
"subdir/deep/deeper_file.md": "# Deep nested test\nVerifying folder structure",
}
for name, content in test_files.items():
path = test_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
console.print(f" Created {len(test_files)} test files in {test_dir}")
# Run sync
sync = GoogleDriveSync()
if not sync.authenticate():
console.print("[red]Auth failed[/red]")
shutil.rmtree(test_dir, ignore_errors=True)
return False
console.print(f"\nScanning...")
files_to_upload, skipped, total = sync.prepare_sync(test_dir, force_sync=True)
upload_count = len(files_to_upload)
console.print(f" Files: {total} total, {upload_count} to upload")
# Verify Drive folder before uploading
console.print(f"\nVerifying Drive folder: AIPass_Test...")
folder_id = sync.get_or_create_project_folder("AIPass_Test")
if not folder_id:
error_msg = sync.last_error or "Unknown error"
console.print(f"[red]FAILED: {error_msg}[/red]")
shutil.rmtree(test_dir, ignore_errors=True)
return False
console.print(f" Drive folder ready: AIPass_Test")
console.print(f"Uploading...")
def show_test_progress(completed, total_upload, _successes):
"""Display test upload progress to console."""
console.print(f" {completed}/{total_upload}")
result = sync.sync_backup_files(
test_dir, "AIPass_Test", "sync test", True,
prepared_files=files_to_upload,
skipped_count=skipped,
total_count=total,
progress_fn=show_test_progress
)
if result.get("error"):
console.print(f"\n[red]FAILED: {result['error']}[/red]")
shutil.rmtree(test_dir, ignore_errors=True)
return False
console.print()
if result["success"]:
console.print(f"[green]Test passed: {result['uploaded']}/{result['total']} files synced[/green]")
else:
console.print(f"[red]Test failed: {result['uploaded']} OK, {result['failed']} failed[/red]")
# Run it again to verify no duplicates
console.print(f"\nRe-running sync (should show 0 to upload)...")
files_to_upload2, _, _ = sync.prepare_sync(test_dir, force_sync=False)
if len(files_to_upload2) == 0:
console.print(f"[green]Dedup check passed: 0 files need re-upload[/green]")
else:
console.print(f"[red]Dedup check failed: {len(files_to_upload2)} files flagged for re-upload[/red]")
# Cleanup local test dir
shutil.rmtree(test_dir, ignore_errors=True)
console.print(f"\nCleaned up local test files")
console.print(f"[dim]Test Drive folder 'AIPass_Test' left on Drive for inspection[/dim]")
return result["success"]
# =============================================
# HANDLE_COMMAND (Drone Integration)
# =============================================
def handle_command(args) -> bool:
"""Route Google Drive sync commands from backup_system orchestrator.
Args:
args: Command-line arguments from CLI parser
Returns:
bool: True if command was handled, False if not a drive sync command
"""
if not hasattr(args, 'command'):
return False
command = args.command
if command in ['--help', '-h', 'help']:
console.print()
console.print("[bold cyan]google_drive_sync - Google Drive Integration[/bold cyan]")
console.print()
console.print("Syncs AIPass backups to Google Drive using OAuth2.")
console.print()
console.print("[yellow]Commands:[/yellow]")
console.print(" drive-test - Test Google Drive connectivity")
console.print(" drive-sync - Sync backup directory to Google Drive")
console.print(" drive-sync --test - Run a small test sync to verify integration")
console.print(" drive-clear-tracker - Clear file tracker cache")
console.print(" drive-stats - Show file tracker statistics")
console.print()
console.print("[yellow]Options:[/yellow]")
console.print(" --project Project name (default: AIPass)")
console.print(" --note Sync note (default: Manual sync)")
console.print(" --force Force upload all files")
console.print()
return True
if command == 'drive-test':
return _test_drive_sync()
elif command == 'drive-sync':
# --test flag routes to test mode
if getattr(args, 'test', False):
return _run_sync_test()
raw_path = getattr(args, 'path', None)
if raw_path:
backup_path = Path(raw_path)
else:
# Default to snapshot backup directory
backup_path = _BACKUP_ROOT / "backups" / "system_snapshot"
if not backup_path.exists():
console.print(f"Error: Backup directory not found: {backup_path}")
return False
project = getattr(args, 'project', 'AIPass') or 'AIPass'
note = getattr(args, 'note', 'Manual sync') or 'Manual sync'
force = getattr(args, 'force', False)
sync = GoogleDriveSync()
if not sync.authenticate():
console.print("[red]FAILED: Could not authenticate with Google Drive[/red]")
return False
limit = getattr(args, 'limit', 0) or 0
# Show last backup timestamps for all modes
from ..handlers.utils.backup_timestamps import get_timestamps, format_age
ts = get_timestamps()
console.print()
console.print("[dim]Last backups:[/dim]")
console.print(f" [dim]Snapshot: {format_age(ts.get('snapshot'))}[/dim]")
console.print(f" [dim]Versioned: {format_age(ts.get('versioned'))}[/dim]")
console.print(f" [dim]Drive sync: {format_age(ts.get('drive_sync'))}[/dim]")
console.print()
# Pre-flight: verify Drive folder FIRST (may reset tracker)
console.print(f"Verifying Drive folder (project: {project})...")
folder_id = sync.get_or_create_project_folder(project)
if not folder_id:
error_msg = sync.last_error or "Unknown error"
console.print(f"[red]FAILED: {error_msg}[/red]")
console.print("[red]Sync aborted - no files uploaded[/red]")
return False
console.print(f" Drive folder ready: {project}")
if sync.tracker_was_reset:
console.print(f"[yellow] Drive folder is new - tracker reset, full re-sync needed[/yellow]")
# Phase 1: Scan (local only, fast) - runs AFTER folder check so tracker is accurate
console.print(f"\nScanning {backup_path}...")
files_to_upload, skipped, total = sync.prepare_sync(backup_path, force, limit)
upload_count = len(files_to_upload)
# Display plan
console.print(f" Files considered: {total}{f' (limited to {limit})' if limit > 0 else ''}")
console.print(f" To upload: {upload_count} ({'forced' if force else 'changed/new'})")
console.print(f" Unchanged: {skipped}")
if upload_count == 0:
console.print("[green]All files up to date - nothing to sync[/green]")
return True
console.print()
# Phase 2: Upload with progress bar
with Progress(
TextColumn("{task.description}"),
BarColumn(bar_width=40),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TextColumn("{task.completed}/{task.total}"),
TextColumn("[green]{task.fields[ok]} OK[/green]"),
TimeElapsedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task_id = progress.add_task("Uploading...", total=upload_count, ok=0)
def show_progress(completed, total_upload, successes):
"""Update progress bar."""
progress.update(task_id, completed=completed, ok=successes)
result = sync.sync_backup_files(
backup_path, project, note, force,
prepared_files=files_to_upload,
skipped_count=skipped,
total_count=total,
progress_fn=show_progress
)
# Update label when complete
progress.update(task_id, description="Uploaded ")
# Check for errors
if result.get("error"):
console.print(f"\n[red]FAILED: {result['error']}[/red]")
console.print("[red]Sync aborted - no files uploaded[/red]")
return False
# Summary
console.print()
if result["success"]:
from ..handlers.utils.backup_timestamps import update_timestamp, get_timestamps, format_age
update_timestamp("drive_sync")
ts = get_timestamps()
console.print(f"[green]Sync complete: {result['uploaded']} uploaded, {result['skipped']} unchanged[/green]")
console.print()
console.print(f"[dim]Backups now:[/dim]")
console.print(f" [dim]Snapshot: {format_age(ts.get('snapshot'))}[/dim]")
console.print(f" [dim]Versioned: {format_age(ts.get('versioned'))}[/dim]")
console.print(f" [dim]Drive sync: {format_age(ts.get('drive_sync'))}[/dim]")
else:
console.print(f"[red]Sync failed: {result['uploaded']} uploaded, {result['failed']} failed, {result['skipped']} unchanged[/red]")
return result["success"]
elif command == 'drive-sync-test':
return _run_sync_test()
elif command == 'drive-clear-tracker':
return _clear_file_tracker()
elif command == 'drive-stats':
return _show_file_tracker_stats()
return False
# =============================================
# CLI/EXECUTION
# =============================================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Google Drive Sync for AIPass Backup System",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
Commands: test, sync, sync-test, clear-tracker, show-stats
test - Test Google Drive connectivity
sync - Sync backup directory to Google Drive
sync-test - Run a small test sync to verify integration
clear-tracker - Clear file tracker cache
show-stats - Show file tracker statistics
OPTIONS:
--project - Project name for sync (default: AIPass)
--note - Note for sync operation (default: Manual sync)
--force - Force sync all files (ignore tracker)
EXAMPLES:
python3 google_drive_sync.py test
python3 google_drive_sync.py sync /path/to/backups
python3 google_drive_sync.py sync /path/to/backups --project "MyProject" --note "Daily backup"
python3 google_drive_sync.py sync /path/to/backups --force
python3 google_drive_sync.py clear-tracker
python3 google_drive_sync.py show-stats
"""
)
parser.add_argument("command",
choices=['test', 'sync', 'sync-test', 'clear-tracker', 'show-stats'],
help="Command to execute")
parser.add_argument("path", nargs='?', help="Backup directory path (required for sync command)")
parser.add_argument("--project", type=str, default="AIPass", help="Project name for sync")
parser.add_argument("--note", type=str, default="Manual sync", help="Note for sync operation")
parser.add_argument("--force", action="store_true", help="Force sync all files (ignore tracker)")
args = parser.parse_args()
# Check if module is enabled
config = _load_config()
if not config.get("config", {}).get("enabled", True):
console.print("Warning: Google Drive sync is disabled")
sys.exit(0)
if args.command == 'clear-tracker':
if _clear_file_tracker():
console.print("File tracker cleared successfully")
sys.exit(0)
else:
console.print("Failed to clear file tracker")
sys.exit(1)
elif args.command == 'show-stats':
if _show_file_tracker_stats():
sys.exit(0)
else:
sys.exit(1)
elif args.command == 'sync':
if not args.path:
console.print("Error: sync command requires a path argument")
console.print("Usage: python3 google_drive_sync.py sync /path/to/backups")
sys.exit(1)
backup_path = Path(args.path)
if not backup_path.exists():
console.print(f"Error: Backup directory not found: {backup_path}")
sys.exit(1)
sync = GoogleDriveSync()
if not sync.authenticate():
console.print("[red]Failed to authenticate with Google Drive[/red]")
sys.exit(1)
# Phase 1: Scan
console.print(f"Scanning {backup_path}...")
files_to_upload, skipped, total = sync.prepare_sync(backup_path, args.force)
upload_count = len(files_to_upload)
console.print(f" Files scanned: {total}")
console.print(f" To upload: {upload_count} ({'forced' if args.force else 'changed/new'})")
console.print(f" Unchanged: {skipped}")
if upload_count == 0:
console.print("[green]All files up to date - nothing to sync[/green]")
sys.exit(0)
console.print(f"\nSyncing to Google Drive (project: {args.project})...")
def cli_progress(completed, total_upload, successes):
"""Display CLI upload progress."""
console.print(f" Progress: {completed}/{total_upload} ({successes} OK)")
# Phase 2: Upload
result = sync.sync_backup_files(
backup_path, args.project, args.note, args.force,
prepared_files=files_to_upload,
skipped_count=skipped,
total_count=total,
progress_fn=cli_progress
)
console.print()
if result["success"]:
console.print(f"[green]Sync complete: {result['uploaded']} uploaded, {result['skipped']} unchanged[/green]")
sys.exit(0)
else:
console.print(f"[yellow]Sync finished: {result['uploaded']} uploaded, {result['failed']} failed, {result['skipped']} unchanged[/yellow]")
sys.exit(1)
elif args.command == 'sync-test':
# Create a minimal args namespace for the test function
if _run_sync_test():
sys.exit(0)
else:
sys.exit(1)
elif args.command == 'test':
if _test_drive_sync():
console.print("Google Drive sync test successful")
sys.exit(0)
else:
console.print("Google Drive sync test failed")
sys.exit(1)
+287
View File
@@ -0,0 +1,287 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: integrations.py - External integrations and backup protection
# Date: 2025-11-29
# Version: 2.1.0
# Category: backup_system
#
# CHANGELOG (Max 5 entries):
# - v2.1.0 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang, sys.path manipulation, prax/cli imports
# * Uses standard logging and rich console
# * Relative handler imports, GoogleDriveSync via sibling module
# - v2.0.1 (2025-11-29): UX FIX - Import error and missing help output
# - v2.0.0 (2025-11-16): Created seed-compliant layout module
# - v1.1.0 (2025-10-30): Original backup_integrations features
# - v1.0.0 (2025-10-14): Initial extraction
#
# CODE STANDARDS:
# - Follow seed 3-layer architecture
# - Orchestrate workflows, delegate to handlers
# - Import handlers, never implement business logic
# =============================================
"""
Backup System Integrations Module
Handles external integrations for the backup system following seed architecture standards.
Provides CLI command routing and integration orchestration.
This module manages optional features that integrate with external services
or provide additional protection mechanisms for backups:
- Google Drive cloud synchronization (optional)
- Backup directory protection (read-only permissions)
Architecture Pattern:
- handle_command(args) - CLI entry point for integration commands
- Integration functions with proper error handling and logging
- Graceful degradation for optional dependencies (Google Drive)
"""
# =============================================
# IMPORTS
# =============================================
import sys
import os
import stat
import logging
from pathlib import Path
from rich.console import Console
logger = logging.getLogger(__name__)
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
# Handler imports - relative imports
from ..handlers.utils.system_utils import safe_print
from ..handlers.operations.integration_ops import (
sync_to_drive as _sync_to_drive_handler,
set_backup_readonly as _set_backup_readonly_handler,
)
# Google Drive sync integration (optional - graceful degradation if not available)
try:
from .google_drive_sync import GoogleDriveSync
DRIVE_SYNC_AVAILABLE = True
except ImportError:
GoogleDriveSync = None # type: ignore
DRIVE_SYNC_AVAILABLE = False
logger.info("[integrations] Google Drive sync module not available - sync features disabled")
# =============================================
# MODULE-LEVEL COMMAND HANDLER
# =============================================
def handle_command(args) -> bool | None:
"""Route integration commands to appropriate handler.
This is the module-level entry point for CLI commands related to integrations.
Routes integration-specific commands like sync-to-drive and set-readonly.
Args:
args: Command-line arguments from CLI parser
Returns:
bool: True if command succeeded, False otherwise, None if command not matched
Example:
result = handle_command(args)
if result:
console.print("Integration command completed successfully")
Supported commands:
- sync-to-drive: Sync backup to Google Drive
- set-readonly: Protect backup with read-only permissions
"""
if not hasattr(args, 'integration_command'):
return None
if args.integration_command == 'sync-to-drive':
backup_path = Path(args.backup_path) if hasattr(args, 'backup_path') else None
source_dir = Path(args.source_dir) if hasattr(args, 'source_dir') else None
mode = getattr(args, 'mode', 'versioned')
backup_note = getattr(args, 'backup_note', '')
if not backup_path or not source_dir:
logger.warning("[integrations] Missing required arguments for sync-to-drive")
return False
return _sync_to_drive(backup_path, source_dir, mode, backup_note)
elif args.integration_command == 'set-readonly':
backup_path = Path(args.backup_path) if hasattr(args, 'backup_path') else None
if not backup_path:
logger.warning("[integrations] Missing backup_path argument for set-readonly")
return False
return _set_backup_readonly(backup_path)
return None
# =============================================
# GOOGLE DRIVE INTEGRATION
# =============================================
def _sync_to_drive(backup_path: Path, source_dir: Path, mode: str, backup_note: str = "") -> bool:
"""Orchestrate sync to Google Drive using integration handler.
Args:
backup_path: Path to backup directory to sync
source_dir: Source directory being backed up
mode: Backup mode ('snapshot' or 'versioned')
backup_note: Optional note describing the backup
Returns:
bool: True if sync succeeded, False otherwise
"""
if not DRIVE_SYNC_AVAILABLE:
safe_print("\033[93m[WARNING] Google Drive sync module not available - check backup_system installation\033[0m")
logger.warning("[integrations] Drive sync requested but google_drive_sync module not found")
return False
if mode != 'versioned':
safe_print("[INFO] Drive sync only available for versioned backups")
logger.info(f"[integrations] Drive sync skipped - mode {mode} not supported (versioned only)")
return False
try:
# Display sync header
safe_print("\n" + "="*70)
safe_print("\033[96m GOOGLE DRIVE SYNC\033[0m")
safe_print("="*70)
project_name = "AIPass"
safe_print(f"\033[92m✓\033[0m Google Drive folder name: \033[1m{project_name}\033[0m")
safe_print(f"\033[92m✓\033[0m Destination: Google Drive (versioned backups)")
safe_print("-"*70)
logger.info("[integrations] Starting Google Drive sync")
safe_print("\033[94m[AUTH]\033[0m Verifying Google Drive credentials...")
success = _sync_to_drive_handler(
backup_path=backup_path,
source_dir=source_dir,
mode=mode,
backup_note=backup_note,
drive_sync_module=GoogleDriveSync,
drive_sync_available=DRIVE_SYNC_AVAILABLE,
)
if success:
safe_print("\033[92m✓ Sync completed successfully\033[0m")
logger.info(f"[integrations] Drive sync completed successfully for {project_name}")
else:
logger.error("[integrations] Drive sync completed with errors")
return success
except Exception as e:
error_msg = f"Drive sync failed: {e}"
safe_print(f"[ERROR] {error_msg}")
logger.error(f"[integrations] {error_msg}")
return False
# =============================================
# FILE PROTECTION
# =============================================
def _set_backup_readonly(backup_path: Path) -> bool:
"""Orchestrate backup directory read-only protection using handler.
Args:
backup_path: Path to backup directory to protect
Returns:
bool: True if protection applied successfully, False otherwise
"""
try:
if not backup_path.exists():
warning_msg = f"Backup path does not exist: {backup_path}"
safe_print(f"[WARNING] {warning_msg}")
logger.warning(f"[integrations] {warning_msg}")
return False
success, message = _set_backup_readonly_handler(backup_path)
if success:
safe_print(f"[PROTECTION] Backup directory set to read-only: {backup_path}")
logger.info(f"[integrations] Read-only set: {backup_path} ({message})")
else:
safe_print(f"[WARNING] {message}")
logger.warning(f"[integrations] {message}")
return success
except Exception as e:
warning_msg = f"Could not set read-only protection: {e}"
safe_print(f"[WARNING] {warning_msg}")
logger.warning(f"[integrations] {warning_msg}")
return False
# =============================================
# MODULE INITIALIZATION
# =============================================
# Log module initialization
logger.info("[integrations] Module loaded - external integration support ready")
if DRIVE_SYNC_AVAILABLE:
logger.info("[integrations] Google Drive sync available")
else:
logger.info("[integrations] Google Drive sync unavailable - install google_drive_sync.py to enable")
# =============================================
# MAIN ENTRY POINT
# =============================================
def print_help():
"""Display help information for the integrations module."""
_header("BACKUP SYSTEM - INTEGRATIONS MODULE")
safe_print("")
safe_print("\033[1mPURPOSE:\033[0m")
safe_print(" External integrations for the backup system")
safe_print("")
safe_print("\033[1mAVAILABLE SUBCOMMANDS:\033[0m")
safe_print("")
safe_print(" \033[92msync-to-drive\033[0m")
safe_print(" Sync versioned backups to Google Drive cloud storage")
safe_print(" Requires: backup_path, source_dir, mode, backup_note")
safe_print(" Status: " + ("\033[92mAvailable\033[0m" if DRIVE_SYNC_AVAILABLE else "\033[93mUnavailable (google_drive_sync module not found)\033[0m"))
safe_print("")
safe_print(" \033[92mset-readonly\033[0m")
safe_print(" Protect backup directory with read-only permissions")
safe_print(" Requires: backup_path")
safe_print(" Status: \033[92mAvailable\033[0m")
safe_print("")
safe_print("\033[1mUSAGE:\033[0m")
safe_print(" This module is called via the backup system CLI:")
safe_print(" \033[90m$ backup integration sync-to-drive --backup-path <path> ...\033[0m")
safe_print(" \033[90m$ backup integration set-readonly --backup-path <path>\033[0m")
safe_print("")
safe_print("\033[1mOR\033[0m import functions directly in Python:")
safe_print(" \033[90mfrom aipass.backup.apps.modules.integrations import handle_command\033[0m")
safe_print("")
safe_print("-"*70)
safe_print("\033[1mCommands:\033[0m sync-to-drive, set-readonly")
safe_print("="*70)
safe_print("")
if __name__ == "__main__":
"""Display help when module is run directly."""
import sys as _sys
if len(_sys.argv) > 1 and _sys.argv[1] in ['--help', '-h', 'help']:
print_help()
else:
print_help()
@@ -0,0 +1,147 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: reauth_drive.py - Google Drive Re-Authentication Module
# Date: 2026-02-21
# Version: 1.2.0
# Category: backup_system
#
# CHANGELOG (Max 5 entries):
# - v1.2.0 (2026-03-06): Adapted for AIPass public repo
# * Removed shebang, sys.path manipulation, prax/cli imports
# * Uses standard logging and rich console
# * Relative handler imports, Google API deps wrapped in try/except
# - v1.1.0 (2026-02-21): Fixed handle_command() signature (command, args) → (args)
# - v1.0.0 (2026-02-20): Initial version - standalone reauth utility
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# =============================================
"""
reauth_drive - Google Drive Re-Authentication Module
Standalone Google Drive re-authentication module.
Uses console-based OAuth flow (no browser needed).
Delegates implementation to reauth_handler.
"""
import sys
import logging
from pathlib import Path
from rich.console import Console
logger = logging.getLogger(__name__)
console = Console()
# Handler imports
from ..handlers.utils.reauth_handler import reauth as _run_reauth
CLIENT_SECRETS = Path(__file__).resolve().parents[1] / 'credentials.json'
CREDS_PATH = Path.home() / '.aipass' / 'drive_creds.json'
def handle_command(args) -> bool:
"""Handle reauth commands routed from the backup_system orchestrator.
Args:
args: Command-line arguments from CLI parser
Returns:
bool: True if command handled, False otherwise
"""
if not hasattr(args, 'command'):
return False
command = args.command
if command in ['--help', '-h', 'help']:
print_help()
return True
if command == 'reauth':
return _execute_reauth()
return False
def print_help():
"""Display help for the reauth_drive module."""
console.print()
console.print("[bold cyan]reauth_drive - Google Drive Re-Authentication[/bold cyan]")
console.print()
console.print("Re-authenticates with Google Drive using console OAuth flow.")
console.print()
console.print("[yellow]Usage:[/yellow]")
console.print(" python3 reauth_drive.py # Run re-authentication")
console.print(" python3 reauth_drive.py --help # Show this help")
console.print()
def _execute_reauth() -> bool:
"""Orchestrate the re-authentication flow with user feedback."""
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request # noqa: F811
from googleapiclient.discovery import build
except ImportError as e:
console.print(f"[red]Missing package: {e}[/red]")
console.print(f"Install: {sys.executable} -m pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib")
return False
if not CLIENT_SECRETS.exists():
console.print(f"[red]ERROR: Client secrets not found at: {CLIENT_SECRETS}[/red]")
logger.warning(f"OAuth client secrets not found at: {CLIENT_SECRETS}")
return False
console.print()
console.print("[bold cyan]" + "="*60 + "[/bold cyan]")
console.print("[bold cyan]GOOGLE DRIVE RE-AUTHENTICATION[/bold cyan]")
console.print("[bold cyan]" + "="*60 + "[/bold cyan]")
# Check for token refresh first
if CREDS_PATH.exists():
try:
creds = Credentials.from_authorized_user_file(str(CREDS_PATH), ['https://www.googleapis.com/auth/drive.file'])
if creds and creds.expired and creds.refresh_token:
console.print("Attempting token refresh...")
logger.info("Attempting Drive token refresh")
except Exception as e:
logger.warning(f"Token refresh check failed: {e}")
console.print()
console.print("Starting local auth server...")
console.print("A URL will be printed below - open it in your browser.")
console.print("After authorizing, the browser will redirect to localhost.")
console.print()
success = _run_reauth(CLIENT_SECRETS)
if success:
console.print("[green]Re-authentication SUCCESSFUL![/green]")
logger.info("Drive re-authentication successful")
# Show account info
try:
creds = Credentials.from_authorized_user_file(str(CREDS_PATH), ['https://www.googleapis.com/auth/drive.file'])
service = build('drive', 'v3', credentials=creds)
about = service.about().get(fields="user,storageQuota").execute()
email = about['user']['emailAddress']
quota = about.get('storageQuota', {})
usage_gb = int(quota.get('usage', 0)) / (1024**3)
limit_gb = int(quota.get('limit', 0)) / (1024**3)
console.print(f"Authenticated as: {email}")
console.print(f"Storage: {usage_gb:.2f} GB / {limit_gb:.2f} GB")
except Exception:
pass
else:
console.print("[red]Re-authentication FAILED[/red]")
logger.error("Drive re-authentication failed")
return success
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']:
print_help()
sys.exit(0)
success = _execute_reauth()
sys.exit(0 if success else 1)
@@ -0,0 +1 @@
# Plugins package - Pluggable components for branch capabilities
View File
View File
+1
View File
@@ -0,0 +1 @@
# Apps package - Branch application modules and handlers
+213
View File
@@ -0,0 +1,213 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: daemon.py - DAEMON Branch Entry Point
# Date: 2026-01-21
# Version: 1.0.0
# Category: daemon
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-01-21): Initial branch creation
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# =============================================
"""
DAEMON Branch - Main Orchestrator
Explicit module imports:
- Imports known modules from modules/ directory
- Routes commands to discovered modules automatically
"""
# Standard library imports
import sys
from typing import List, Any
# Logger
import logging
logger = logging.getLogger(__name__)
# Console
from rich.console import Console
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
# =============================================================================
# MODULE DISCOVERY
# =============================================================================
from .modules import update, schedule, activity_report, actions
def get_modules() -> List[Any]:
"""
Return list of known modules that implement handle_command().
Returns:
List of module objects with handle_command function
"""
modules = []
for mod in [update, schedule, activity_report, actions]:
if hasattr(mod, 'handle_command'):
modules.append(mod)
return modules
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
"""
Route command to appropriate module
Args:
command: Command name (e.g., 'create', 'update', 'list')
args: Additional arguments
modules: List of discovered modules
Returns:
True if command was handled, False otherwise
"""
for module in modules:
try:
if module.handle_command(command, args):
return True
except Exception as e:
logger.error(f"[DAEMON] Module {module.__name__} error: {e}")
return False
# =============================================================================
# INTROSPECTION DISPLAY
# =============================================================================
def print_introspection(modules: List[Any]):
"""Display discovered modules when run without arguments"""
console.print()
console.print("[bold cyan]DAEMON - Branch Management System[/bold cyan]")
console.print()
console.print("[dim]Module orchestration[/dim]")
console.print()
console.print(f"[yellow]Modules:[/yellow] {len(modules)}")
console.print()
if modules:
for module in modules:
module_name = module.__name__.split('.')[-1]
# Get first line of docstring
description = "No description"
if module.__doc__:
description = module.__doc__.strip().split('\n')[0]
console.print(f" [cyan]*[/cyan] {module_name:20} [dim]{description}[/dim]")
else:
console.print(" [dim]No modules discovered[/dim]")
console.print()
console.print("[dim]Run 'daemon --help' for usage information[/dim]")
console.print()
# =============================================================================
# DRONE COMPLIANCE - HELP SYSTEM
# =============================================================================
def print_help(modules: List[Any]):
"""Display Rich-formatted help"""
console.print()
_header("DAEMON - Branch Management System")
console.print()
console.print("[dim]Module orchestration[/dim]")
console.print()
console.print("-" * 70)
console.print()
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
console.print(" [dim]daemon <command> [args...][/dim]")
console.print(" [dim]daemon --help[/dim]")
console.print()
console.print("-" * 70)
console.print()
console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]")
console.print()
if modules:
for module in modules:
module_name = module.__name__.split('.')[-1]
# Get first line of docstring
description = "No description"
if module.__doc__:
description = module.__doc__.strip().split('\n')[0]
console.print(f" [green]{module_name:20}[/green] [dim]{description}[/dim]")
else:
console.print(" [dim]No modules discovered[/dim]")
console.print()
console.print("-" * 70)
console.print()
console.print("[bold]TIP:[/bold] For module-specific help:")
console.print(" [dim]daemon <command> --help[/dim]")
console.print()
# =============================================================================
# MAIN ENTRY POINT
# =============================================================================
def main():
"""Main entry point - routes commands or shows help"""
# Get available modules
modules = get_modules()
# Parse arguments
args = sys.argv[1:]
# Show introspection when run with no arguments
if len(args) == 0:
print_introspection(modules)
return 0
# Version flag
if args[0] in ['--version', '-V']:
console.print("DAEMON v1.0.0")
return 0
# Show help for explicit help flags
if args[0] in ['--help', '-h', 'help']:
print_help(modules)
return 0
# Extract command and remaining args
command = args[0]
remaining_args = args[1:] if len(args) > 1 else []
# Route to modules
if route_command(command, remaining_args, modules):
return 0
else:
console.print()
console.print(f"[red]Unknown command: {command}[/red]")
console.print()
console.print("Run [dim]daemon --help[/dim] for available commands")
console.print()
return 1
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
console.print("\n\nOperation cancelled by user")
sys.exit(0)
except Exception as e:
logger.error(f"DAEMON entry point error: {e}", exc_info=True)
console.print(f"\nError: {e}")
sys.exit(1)
+296
View File
@@ -0,0 +1,296 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: daemon_wakeup.py - DAEMON Wake-Up Cron Trigger
# Date: 2026-02-15
# Version: 1.0.0
# Category: daemon/apps
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-15): Initial implementation - cron-triggered wake-up checker
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No Rich console (headless cron execution)
# - Stdout logging (cron redirects to logs/daemon_wakeup.log)
# - stdlib only + optional imports
# =============================================
"""
Cron trigger script for the DAEMON wake-up system.
Called periodically by cron. Standalone script -- not imported as a module.
Flow:
1. Acquire single-instance lock
2. Send Telegram "waking up" notification via daemon bot (optional)
3. Check daemon's email inbox (new/opened counts)
4. Build summary report with sender/subject listings
5. Send report notification via daemon bot (optional)
"""
# =============================================
# IMPORTS
# =============================================
import sys
import json
import fcntl
import time
from pathlib import Path
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
# =============================================
# OPTIONAL IMPORTS
# =============================================
# Telegram notifications (optional) — use absolute imports for standalone script
try:
from aipass.daemon.apps.handlers.schedule.assistant_notifier import (
notify_wakeup,
notify_report,
notify_error,
)
TELEGRAM_AVAILABLE = True
except ImportError:
TELEGRAM_AVAILABLE = False
notify_wakeup = None
notify_report = None
notify_error = None
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
LOCK_FILE = JSON_DIR / "wakeup.lock"
CHAT_LOCK_FILE = JSON_DIR / "chat.lock"
INBOX_PATH = _DAEMON_ROOT / "ai_mail.local" / "inbox.json"
# =============================================
# LOGGING
# =============================================
def log(message: str) -> None:
"""Print timestamped log line to stdout (captured by cron redirect)."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {message}", flush=True)
# =============================================
# CHAT LOCK CHECK
# =============================================
def is_chat_active() -> bool:
"""
Check if daemon is in an active Telegram chat session.
The chat listener creates a lock file while running.
If the lock exists and is fresh (<60 minutes), skip the wake-up cycle
to avoid interfering with the live conversation.
Returns:
True if chat session is active, False otherwise
"""
if not CHAT_LOCK_FILE.exists():
return False
try:
age = time.time() - CHAT_LOCK_FILE.stat().st_mtime
if age > 3600: # 60 minutes - treat as stale
return False
return True
except OSError:
return False
# =============================================
# EMAIL CHECK
# =============================================
def check_inbox() -> dict:
"""
Check daemon's email inbox for new and opened emails.
Reads inbox.json directly (stdlib only, no module imports that
require Rich/console).
Returns:
Dict with keys: new_count, opened_count, emails (list of brief dicts)
"""
result = {
"new_count": 0,
"opened_count": 0,
"emails": [],
}
if not INBOX_PATH.exists():
log("Inbox file not found, skipping email check")
return result
try:
with open(INBOX_PATH, "r", encoding="utf-8") as f:
inbox_data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
log(f"WARNING: Failed to read inbox: {e}")
return result
messages = inbox_data.get("messages", [])
for msg in messages:
status = msg.get("status", "")
if status == "new":
result["new_count"] += 1
result["emails"].append({
"from": msg.get("from", "unknown"),
"subject": msg.get("subject", "(no subject)"),
"status": "new",
})
elif status == "opened":
result["opened_count"] += 1
result["emails"].append({
"from": msg.get("from", "unknown"),
"subject": msg.get("subject", "(no subject)"),
"status": "opened",
})
return result
# =============================================
# REPORT BUILDER
# =============================================
def build_report(inbox: dict) -> str:
"""
Build a summary report from inbox check results.
Args:
inbox: Dict from check_inbox()
Returns:
Formatted report string
"""
new_count = inbox["new_count"]
opened_count = inbox["opened_count"]
total_unread = new_count + opened_count
emails = inbox["emails"]
lines = []
if total_unread == 0:
lines.append("No new emails")
else:
lines.append(f"New: {new_count} | Opened: {opened_count}")
# List up to 10 most recent unread emails (brief, 1 line each)
shown = emails[:10]
for email in shown:
marker = "[NEW]" if email["status"] == "new" else "[OPENED]"
subject = email["subject"][:50]
lines.append(f" {marker} {email['from']}: {subject}")
if len(emails) > 10:
lines.append(f" ... and {len(emails) - 10} more")
return "\n".join(lines)
# =============================================
# MAIN
# =============================================
def main() -> int:
"""
Main cron entry point.
Returns:
0 on success, 1 on error
"""
log("=" * 60)
log("Daemon wake-up triggered")
# Check if chat session is active (skip wake-up to avoid interference)
if is_chat_active():
log("Chat session active, skipping wake-up cycle")
return 0
# Ensure lock directory exists
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
# Acquire single-instance lock (non-blocking, stdlib fcntl)
lock_fd = open(LOCK_FILE, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
log("Another instance already running, skipping.")
lock_fd.close()
return 0
try:
return _run_locked()
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def _run_locked() -> int:
"""Execute the wake-up job while holding the lock."""
exit_code = 0
# Step 1: Send "waking up" notification (optional)
if TELEGRAM_AVAILABLE:
try:
notify_wakeup()
log("Telegram: wakeup notification sent")
except Exception as e:
log(f"WARNING: Telegram wakeup notification failed: {e}")
# Step 2: Check inbox
try:
inbox = check_inbox()
log(f"Inbox: {inbox['new_count']} new, {inbox['opened_count']} opened")
except Exception as e:
log(f"CRITICAL: Unhandled error in check_inbox: {e}")
if TELEGRAM_AVAILABLE:
try:
notify_error(f"Inbox check failed: {e}")
except Exception:
pass
return 1
# Step 3: Build report
report = build_report(inbox)
log(f"Report: {report.splitlines()[0]}")
# Step 4: Send report notification (optional)
if TELEGRAM_AVAILABLE:
try:
notify_report(report)
log("Telegram: report notification sent")
except Exception as e:
log(f"WARNING: Telegram report notification failed: {e}")
exit_code = 1
log("Daemon wake-up finished")
log("=" * 60)
return exit_code
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
# Last-resort catch -- never crash silently
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] FATAL: Unhandled exception: {e}", flush=True)
if TELEGRAM_AVAILABLE:
try:
notify_error(f"FATAL: {e}")
except Exception:
pass
sys.exit(1)
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
@@ -0,0 +1 @@
"""Daemon handlers package."""
@@ -0,0 +1,496 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: actions_registry.py - Numbered Action Registry
# Date: 2026-03-02
# Version: 1.0.0
# Category: daemon/handlers/actions
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-02): Initial creation - DPLAN-043
# * Sequential 4-digit IDs (0001-9999)
# * JSON storage with CRUD operations
# * Plugin auto-migration from PLUGIN_CONFIG
# * Due-checking logic for all schedule types
# * Supports: plugin, schedule, reminder action types
#
# CODE STANDARDS:
# - Handler independence: pure business logic
# - No Rich console (headless compatible)
# =============================================
"""
Numbered Action Registry — DPLAN-043
Central registry for all scheduled actions. Each action gets a sequential
numeric ID (0001, 0002, ...) and can be individually toggled on/off.
Replaces the old all-or-nothing daemon + kill switch model with granular
per-action control.
Action types:
- plugin: Backed by a plugin file in apps/plugins/ (migrated from existing system)
- schedule: Custom recurring action (dispatches via wake.py)
- reminder: One-shot action that auto-completes after firing
"""
import json
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Paths
_DAEMON_ROOT = Path(__file__).resolve().parents[4] # src/aipass/daemon/
REGISTRY_FILE = _DAEMON_ROOT / "daemon_json" / "actions_registry.json"
PLUGINS_DIR = _DAEMON_ROOT / "apps" / "plugins"
def _empty_registry() -> dict:
"""Return a fresh empty registry structure (avoids shared mutable state)."""
return {"version": 1, "next_id": 1, "actions": []}
# =============================================
# STORAGE
# =============================================
def load_registry() -> dict:
"""Load the actions registry from disk. Returns empty registry if missing."""
if not REGISTRY_FILE.exists():
return _empty_registry().copy()
try:
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if "actions" not in data:
data["actions"] = []
if "next_id" not in data:
data["next_id"] = 1
return data
except (json.JSONDecodeError, OSError) as e:
logger.error("[actions_registry] Failed to load: %s", e)
return _empty_registry().copy()
def save_registry(data: dict) -> bool:
"""Save the actions registry to disk. Returns True on success."""
try:
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(REGISTRY_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
f.write("\n")
return True
except OSError as e:
logger.error("[actions_registry] Failed to save: %s", e)
return False
# =============================================
# ID GENERATION
# =============================================
def _get_next_id(registry: dict) -> str:
"""Get next sequential ID as 4-digit string. Advances next_id."""
next_num = registry.get("next_id", 1)
action_id = f"{next_num:04d}"
registry["next_id"] = next_num + 1
return action_id
# =============================================
# CRUD OPERATIONS
# =============================================
def create_action(
name: str,
action_type: str,
schedule_type: str,
target_branch: str = "",
prompt: str = "",
time: Optional[str] = None,
interval_minutes: Optional[int] = None,
due_date: Optional[str] = None,
fresh: bool = True,
max_turns: int = 50,
enabled: bool = True,
self_dispatch: bool = False,
plugin_file: Optional[str] = None,
) -> dict:
"""
Create a new action and save to registry.
Args:
name: Human-readable action name (e.g., "daily_audit")
action_type: "plugin" | "schedule" | "reminder"
schedule_type: "daily" | "hourly" | "interval" | "once"
target_branch: Target branch email (e.g., "@seed")
prompt: What the dispatched agent should do
time: For daily: "HH:MM", for hourly: "MM"
interval_minutes: For interval schedule type
due_date: For reminder (once) type, ISO date string
fresh: Start fresh session (True) or resume (False)
max_turns: Max agent turns
enabled: Active by default
self_dispatch: Plugin handles its own dispatch
plugin_file: Plugin filename (without .py) for plugin-backed actions
Returns:
The created action dict
"""
registry = load_registry()
action_id = _get_next_id(registry)
action = {
"id": action_id,
"name": name,
"type": action_type,
"schedule_type": schedule_type,
"time": time,
"interval_minutes": interval_minutes,
"due_date": due_date,
"target_branch": target_branch,
"prompt": prompt,
"fresh": fresh,
"max_turns": max_turns,
"enabled": enabled,
"self_dispatch": self_dispatch,
"plugin_file": plugin_file,
"last_run": None,
"next_run": None,
"created": datetime.now().isoformat(),
"completed": None,
}
registry["actions"].append(action)
save_registry(registry)
logger.info("[actions_registry] Created action %s: %s (%s)", action_id, name, action_type)
return action
def get_action(action_id: str) -> Optional[dict]:
"""Get a single action by ID. Returns None if not found."""
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
return action
return None
def list_actions(include_completed: bool = False) -> list:
"""
List all actions.
Args:
include_completed: If True, include completed reminders.
Returns:
List of action dicts.
"""
registry = load_registry()
actions = registry["actions"]
if not include_completed:
actions = [a for a in actions if a.get("completed") is None]
return actions
def toggle_action(action_id: str, enabled: bool) -> bool:
"""Toggle an action on or off. Returns True if found and updated."""
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
action["enabled"] = enabled
save_registry(registry)
state = "enabled" if enabled else "disabled"
logger.info("[actions_registry] Action %s %s: %s", action_id, state, action["name"])
return True
return False
def delete_action(action_id: str) -> bool:
"""Delete an action by ID. Returns True if found and removed."""
registry = load_registry()
original_len = len(registry["actions"])
registry["actions"] = [a for a in registry["actions"] if a["id"] != action_id]
if len(registry["actions"]) < original_len:
save_registry(registry)
logger.info("[actions_registry] Deleted action %s", action_id)
return True
return False
def update_last_run(action_id: str, timestamp: Optional[str] = None) -> bool:
"""Update last_run timestamp for an action. Returns True if found."""
if timestamp is None:
timestamp = datetime.now().isoformat()
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
action["last_run"] = timestamp
action["next_run"] = calc_next_run(action)
save_registry(registry)
return True
return False
def mark_reminder_completed(action_id: str) -> bool:
"""Mark a reminder as completed (one-shot). Returns True if found."""
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
action["completed"] = datetime.now().isoformat()
action["enabled"] = False
save_registry(registry)
logger.info("[actions_registry] Reminder %s completed: %s", action_id, action["name"])
return True
return False
# =============================================
# DUE CHECKING
# =============================================
def is_action_due(action: dict) -> bool:
"""
Check if an action should run now.
For daily: matches current hour:minute, hasn't run today
For hourly: matches current minute, hasn't run this hour
For interval: enough time has elapsed since last run
For once (reminder): due_date <= today, not completed
"""
if not action.get("enabled", False):
return False
if action.get("completed"):
return False
now = datetime.now()
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
target_time = action.get("time", "00:00")
try:
target_h, target_m = map(int, target_time.split(":"))
except (ValueError, AttributeError):
return False
if now.hour != target_h or now.minute != target_m:
return False
last_run = action.get("last_run")
if last_run:
try:
last_dt = datetime.fromisoformat(last_run)
if last_dt.date() == now.date():
return False
except (ValueError, TypeError):
pass
return True
elif schedule_type == "hourly":
target_m_str = action.get("time", "0")
try:
target_m = int(target_m_str)
except (ValueError, TypeError):
return False
if now.minute != target_m:
return False
last_run = action.get("last_run")
if last_run:
try:
last_dt = datetime.fromisoformat(last_run)
if last_dt.hour == now.hour and last_dt.date() == now.date():
return False
except (ValueError, TypeError):
pass
return True
elif schedule_type == "interval":
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if not last_run:
return True
try:
last_dt = datetime.fromisoformat(last_run)
elapsed = (now - last_dt).total_seconds() / 60
return elapsed >= interval
except (ValueError, TypeError):
return True
elif schedule_type == "once":
due_date = action.get("due_date")
if not due_date:
return False
try:
due_dt = datetime.fromisoformat(due_date).date() if "T" in due_date else datetime.strptime(due_date, "%Y-%m-%d").date()
return now.date() >= due_dt
except (ValueError, TypeError):
return False
return False
def calc_next_run(action: dict) -> Optional[str]:
"""Calculate the next run time for an action. Returns ISO string or None."""
now = datetime.now()
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
target_time = action.get("time", "00:00")
try:
target_h, target_m = map(int, target_time.split(":"))
except (ValueError, AttributeError):
return None
next_dt = now.replace(hour=target_h, minute=target_m, second=0, microsecond=0)
if next_dt <= now:
next_dt += timedelta(days=1)
return next_dt.isoformat()
elif schedule_type == "hourly":
target_m_str = action.get("time", "0")
try:
target_m = int(target_m_str)
except (ValueError, TypeError):
return None
next_dt = now.replace(minute=target_m, second=0, microsecond=0)
if next_dt <= now:
next_dt += timedelta(hours=1)
return next_dt.isoformat()
elif schedule_type == "interval":
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if not last_run:
return now.isoformat()
try:
last_dt = datetime.fromisoformat(last_run)
return (last_dt + timedelta(minutes=interval)).isoformat()
except (ValueError, TypeError):
return now.isoformat()
elif schedule_type == "once":
due_date = action.get("due_date")
if due_date and not action.get("completed"):
return due_date
return None
return None
def next_due_str(action: dict) -> str:
"""Human-readable next due string for display."""
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
return f"daily @ {action.get('time', '00:00')}"
elif schedule_type == "hourly":
m = action.get("time", "0")
return f"hourly @ :{int(m):02d}"
elif schedule_type == "interval":
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if last_run:
try:
last_dt = datetime.fromisoformat(last_run)
next_dt = last_dt + timedelta(minutes=interval)
if next_dt <= datetime.now():
return "now"
return next_dt.strftime("%H:%M")
except (ValueError, TypeError):
return "now"
return "now"
elif schedule_type == "once":
return action.get("due_date", "unknown")
return "unknown"
# =============================================
# PLUGIN MIGRATION
# =============================================
def migrate_plugins() -> int:
"""
Scan plugins/ directory and auto-register any plugins not yet in the registry.
Maps PLUGIN_CONFIG fields to action fields. Preserves last_run timestamps
from .last_run.json.
Returns:
Number of newly migrated plugins.
"""
registry = load_registry()
existing_plugins = {
a["plugin_file"]
for a in registry["actions"]
if a.get("plugin_file")
}
# Load last_run data for timestamp preservation
last_run_file = PLUGINS_DIR / ".last_run.json"
last_run_map = {}
if last_run_file.exists():
try:
last_run_map = json.loads(last_run_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
# Discover plugins
migrated = 0
for plugin_path in sorted(PLUGINS_DIR.glob("*.py")):
if plugin_path.name.startswith("_"):
continue
plugin_name = plugin_path.stem
if plugin_name in existing_plugins:
continue
# Import plugin to read PLUGIN_CONFIG
try:
import importlib
# Use absolute package path for plugin import
spec_name = f"aipass.daemon.apps.plugins.{plugin_name}"
module = importlib.import_module(spec_name)
if not hasattr(module, 'PLUGIN_CONFIG'):
continue
config = module.PLUGIN_CONFIG
except Exception as e:
logger.warning("[actions_registry] Failed to import plugin %s: %s", plugin_name, e)
continue
# Map PLUGIN_CONFIG to action fields
action_id = _get_next_id(registry)
action = {
"id": action_id,
"name": config.get("name", plugin_name),
"type": "plugin",
"schedule_type": config.get("schedule", "interval"),
"time": config.get("time"),
"interval_minutes": config.get("interval_minutes"),
"due_date": None,
"target_branch": config.get("branch", ""),
"prompt": config.get("prompt", ""),
"fresh": config.get("fresh", True),
"max_turns": config.get("max_turns", 50),
"enabled": config.get("enabled", False),
"self_dispatch": config.get("self_dispatch", False),
"plugin_file": plugin_name,
"last_run": last_run_map.get(config.get("name", plugin_name)),
"next_run": None,
"created": datetime.now().isoformat(),
"completed": None,
}
# Calculate next_run from last_run
action["next_run"] = calc_next_run(action)
registry["actions"].append(action)
migrated += 1
logger.info("[actions_registry] Migrated plugin: %s -> action %s", plugin_name, action_id)
if migrated > 0:
save_registry(registry)
logger.info("[actions_registry] Migration complete: %d plugin(s) migrated", migrated)
return migrated
@@ -0,0 +1 @@
"""JSON Handlers - Universal JSON operations for Seed branch"""
@@ -0,0 +1,255 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: json_handler.py - JSON Auto-Creating Handler
# Date: 2025-11-21
# Version: 1.2.0
# Category: daemon/handlers/json
#
# CHANGELOG (Max 5 entries):
# - v1.2.0 (2026-01-29): Fixed paths to point to DAEMON branch
# - v1.1.0 (2025-11-21): Refactored to comply with error handling
# - v1.0.0 (2025-11-13): Initial JSON auto-creation system
#
# CODE STANDARDS:
# - Pure functions with proper error raising
# - No Prax imports (handler tier 3)
# =============================================
"""
JSON handler for DAEMON branch.
Provides auto-creating JSON file management with templates.
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any, Optional
import inspect
# Constants
_DAEMON_ROOT = Path(__file__).resolve().parents[4] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
JSON_TEMPLATES_DIR = _DAEMON_ROOT / "apps" / "json_templates"
def _get_caller_module_name() -> str:
"""
Auto-detect calling module name from call stack.
Returns:
Module name (e.g., "imports_standard" from imports_standard.py)
"""
stack = inspect.stack()
if len(stack) > 2:
caller_frame = stack[2]
caller_path = Path(caller_frame.filename)
module_name = caller_path.stem
if module_name and not module_name.startswith('_'):
return module_name
return "unknown"
def load_template(json_type: str, module_name: str) -> Any:
"""Load JSON template from template file."""
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}")
with open(template_path, 'r', encoding='utf-8') as f:
template = json.load(f)
template_str = json.dumps(template)
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
template_str = template_str.replace("2026-01-21", datetime.now().date().isoformat())
return json.loads(template_str)
def validate_json_structure(data: Any, json_type: str) -> bool:
"""Validate JSON structure matches expected type."""
if json_type == "config":
if not isinstance(data, dict):
return False
required = ["module_name", "version", "config"]
return all(key in data for key in required)
elif json_type == "data":
if not isinstance(data, dict):
return False
required = ["created", "last_updated"]
return all(key in data for key in required)
elif json_type == "log":
return isinstance(data, list)
return False
def get_json_path(module_name: str, json_type: str) -> Path:
"""Get path for module JSON file."""
filename = f"{module_name}_{json_type}.json"
return JSON_DIR / filename
def ensure_json_exists(module_name: str, json_type: str) -> bool:
"""Ensure JSON file exists, create from template if missing."""
JSON_DIR.mkdir(parents=True, exist_ok=True)
json_path = get_json_path(module_name, json_type)
if json_path.exists():
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if validate_json_structure(data, json_type):
return True
except json.JSONDecodeError:
pass # File corrupted, regenerate
except OSError:
pass # File unreadable, regenerate
template = load_template(json_type, module_name)
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(template, f, indent=2, ensure_ascii=False)
return True
def load_json(module_name: str, json_type: str) -> Optional[Any]:
"""Load JSON file, auto-create if missing."""
if not ensure_json_exists(module_name, json_type):
return None
json_path = get_json_path(module_name, json_type)
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_json(module_name: str, json_type: str, data: Any) -> bool:
"""Save JSON file."""
json_path = get_json_path(module_name, json_type)
if not validate_json_structure(data, json_type):
raise ValueError(f"Invalid structure for {json_type} JSON")
if json_type == "data" and isinstance(data, dict):
data["last_updated"] = datetime.now().date().isoformat()
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
def ensure_module_jsons(module_name: str) -> bool:
"""Ensure all 3 JSON files exist for a module."""
ensure_json_exists(module_name, "config")
ensure_json_exists(module_name, "data")
ensure_json_exists(module_name, "log")
return True
def log_operation(operation: str, data: Optional[Dict[str, Any]] = None, module_name: Optional[str] = None) -> bool:
"""
Add entry to module log with automatic rotation.
Auto-detects calling module if module_name not provided.
Implements config-controlled log limits to prevent unbounded growth.
When max_log_entries is reached, removes oldest entries (FIFO).
Args:
operation: Operation name to log
data: Optional data dict
module_name: Optional module name (auto-detected if not provided)
Returns:
True if successful, False otherwise
"""
if module_name is None:
module_name = _get_caller_module_name()
ensure_module_jsons(module_name)
config = load_json(module_name, "config")
max_entries = 100
if config and "config" in config:
max_entries = config["config"].get("max_log_entries", 100)
log: List[Dict[str, Any]] = load_json(module_name, "log") or []
entry: Dict[str, Any] = {
"timestamp": datetime.now().isoformat(),
"operation": operation
}
if data:
entry["data"] = data
log.append(entry)
if len(log) > max_entries:
log = log[-max_entries:]
return save_json(module_name, "log", log)
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
"""Increment a counter in data JSON."""
ensure_module_jsons(module_name)
data = load_json(module_name, "data")
if data is None:
return False
if counter_name not in data:
data[counter_name] = 0
data[counter_name] += amount
return save_json(module_name, "data", data)
def update_data_metrics(module_name: str, **metrics: Any) -> bool:
"""Update data metrics."""
ensure_module_jsons(module_name)
data = load_json(module_name, "data")
if data is None:
return False
for key, value in metrics.items():
data[key] = value
return save_json(module_name, "data", data)
if __name__ == "__main__":
from rich.console import Console
from rich.panel import Panel
console = Console()
console.print()
console.print(Panel.fit(
"[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]",
border_style="bright_blue"
))
console.print()
console.print("[yellow]TESTING:[/yellow] Creating daemon JSONs...")
log_operation("test_operation", {"test": "data"}, "daemon")
increment_counter("daemon", "test_counter", 1)
update_data_metrics("daemon", test_metric="working")
console.print()
console.print(f"[green]Check {JSON_DIR}/ for created files:[/green]")
console.print(" [dim]-[/dim] daemon_config.json")
console.print(" [dim]-[/dim] daemon_data.json")
console.print(" [dim]-[/dim] daemon_log.json")
console.print()
@@ -0,0 +1,56 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: __init__.py - Monitoring Handlers Package
# Date: 2026-01-30
# Version: 0.1.0
# Category: assistant/handlers/monitoring
#
# CHANGELOG (Max 5 entries):
# - v0.1.0 (2026-01-30): Initial implementation - FPLAN-0266 Phase 1
#
# CODE STANDARDS:
# - Handler package init - no Prax imports
# - Part of Branch Activity Monitoring System
# =============================================
"""
Monitoring handlers for ASSISTANT branch.
Provides activity collection and memory health checking
for the Branch Activity Monitoring System (FPLAN-0266).
"""
from .activity_collector import (
load_branch_registry,
get_branch_paths,
scan_branch_activity,
get_all_branch_activity,
)
from .memory_health import (
check_memory_files_exist,
validate_memory_structure,
check_freshness,
get_memory_health_status,
)
from .red_flag_detector import (
get_branch_status,
get_red_flag_summary,
)
__all__ = [
# activity_collector
'load_branch_registry',
'get_branch_paths',
'scan_branch_activity',
'get_all_branch_activity',
# memory_health
'check_memory_files_exist',
'validate_memory_structure',
'check_freshness',
'get_memory_health_status',
# red_flag_detector
'get_branch_status',
'get_red_flag_summary',
]
@@ -0,0 +1,318 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: activity_collector.py - Branch Activity Data Collector
# Date: 2026-01-30
# Version: 0.1.0
# Category: daemon/handlers/monitoring
#
# CHANGELOG (Max 5 entries):
# - v0.1.0 (2026-01-30): Initial implementation - FPLAN-0266 Phase 1
#
# CODE STANDARDS:
# - Handler implements collection logic, module orchestrates
# - NO Prax logger in handlers (handlers don't log)
# - Uses pathlib for all path operations
# - Standard library only
# =============================================
"""
Branch Activity Data Collector Handler
Collects activity data from all branches in the AIPass system.
Scans for code files (.py) and memory files (.local.json, .observations.json, README.md).
Provides file modification timestamps for activity tracking.
"""
import os
import json
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
# Constants
REGISTRY_PATH = Path(os.environ.get('AIPASS_REGISTRY', Path.home() / '.aipass' / 'AIPASS_REGISTRY.json'))
MEMORY_FILE_PATTERNS = [".local.json", ".observations.json", "README.md"]
CODE_FILE_EXTENSION = ".py"
def load_branch_registry() -> Dict[str, Any]:
"""
Load the BRANCH_REGISTRY.json file.
Returns:
Dict containing registry data with 'metadata' and 'branches' keys.
Returns empty dict with empty branches list on error.
"""
if not REGISTRY_PATH.exists():
return {"metadata": {}, "branches": []}
try:
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {"metadata": {}, "branches": []}
def get_branch_paths() -> List[Dict[str, str]]:
"""
Get all branch names and paths from the registry.
Returns:
List of dicts with 'name' and 'path' keys for each branch.
"""
registry = load_branch_registry()
branches = registry.get("branches", [])
return [
{"name": b.get("name", ""), "path": b.get("path", "")}
for b in branches
if b.get("name") and b.get("path")
]
def _get_file_mtime(file_path: Path) -> Optional[datetime]:
"""
Get modification time of a file.
Args:
file_path: Path to the file.
Returns:
datetime of last modification, or None if file doesn't exist.
"""
try:
if file_path.exists():
return datetime.fromtimestamp(file_path.stat().st_mtime)
except OSError:
pass
return None
def _is_memory_file(file_path: Path, branch_name: str) -> bool:
"""
Check if a file is a memory file for this branch.
Memory files follow patterns:
- [BRANCH].local.json
- [BRANCH].observations.json
- README.md
- DASHBOARD.local.json
Args:
file_path: Path to check.
branch_name: Name of the branch (uppercase).
Returns:
True if file is a memory file.
"""
name = file_path.name
# Check for branch-specific memory files
if name == f"{branch_name}.local.json":
return True
if name == f"{branch_name}.observations.json":
return True
if name == "README.md":
return True
if name == "DASHBOARD.local.json":
return True
return False
def _scan_directory_files(
directory: Path,
branch_name: str,
since: Optional[datetime] = None,
max_depth: int = 5
) -> Dict[str, List[Dict[str, Any]]]:
"""
Scan a directory for code and memory files.
Args:
directory: Directory to scan.
branch_name: Name of the branch (uppercase).
since: Only include files modified since this time.
max_depth: Maximum directory depth to scan.
Returns:
Dict with 'code_files' and 'memory_files' lists.
"""
code_files: List[Dict[str, Any]] = []
memory_files: List[Dict[str, Any]] = []
if not directory.exists() or not directory.is_dir():
return {"code_files": code_files, "memory_files": memory_files}
def scan_recursive(path: Path, depth: int = 0) -> None:
if depth > max_depth:
return
try:
for item in path.iterdir():
# Skip hidden directories and __pycache__
if item.is_dir():
if item.name.startswith('.') or item.name == '__pycache__':
continue
scan_recursive(item, depth + 1)
elif item.is_file():
mtime = _get_file_mtime(item)
if mtime is None:
continue
# Apply time filter if specified
if since and mtime < since:
continue
file_info = {
"path": str(item),
"name": item.name,
"mtime": mtime.isoformat(),
"mtime_datetime": mtime,
}
# Categorize file
if _is_memory_file(item, branch_name):
memory_files.append(file_info)
elif item.suffix == CODE_FILE_EXTENSION:
code_files.append(file_info)
except PermissionError:
pass
except OSError:
pass
scan_recursive(directory)
return {"code_files": code_files, "memory_files": memory_files}
def scan_branch_activity(
branch_name: str,
branch_path: str,
since: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Scan a single branch directory and collect file modification times.
Args:
branch_name: Name of the branch (e.g., "DRONE").
branch_path: Absolute path to the branch directory.
since: Only include files modified since this time.
Defaults to last 24 hours if not specified.
Returns:
Dict with structure:
{
"branch_name": str,
"path": str,
"code_files": [{"path": str, "name": str, "mtime": str}],
"memory_files": [{"path": str, "name": str, "mtime": str}],
"last_activity": str (ISO format) or None,
"total_files": int,
"scan_time": str
}
"""
# Default to last 24 hours
if since is None:
since = datetime.now() - timedelta(hours=24)
directory = Path(branch_path)
scan_result = _scan_directory_files(directory, branch_name, since)
# Find the most recent activity
all_files = scan_result["code_files"] + scan_result["memory_files"]
last_activity = None
if all_files:
most_recent = max(all_files, key=lambda f: f["mtime_datetime"])
last_activity = most_recent["mtime"]
# Clean up internal datetime objects before returning
for f in scan_result["code_files"]:
del f["mtime_datetime"]
for f in scan_result["memory_files"]:
del f["mtime_datetime"]
return {
"branch_name": branch_name,
"path": branch_path,
"code_files": scan_result["code_files"],
"memory_files": scan_result["memory_files"],
"last_activity": last_activity,
"total_files": len(all_files),
"scan_time": datetime.now().isoformat(),
}
def get_all_branch_activity(
since: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Collect activity data for ALL branches in the system.
Args:
since: Only include files modified since this time.
Defaults to last 24 hours if not specified.
Returns:
Dict with structure:
{
"scan_time": str,
"time_window_hours": float,
"branches_scanned": int,
"branches_with_activity": int,
"total_files_modified": int,
"branches": {
"BRANCH_NAME": {branch activity dict}
}
}
"""
if since is None:
since = datetime.now() - timedelta(hours=24)
time_window_hours = (datetime.now() - since).total_seconds() / 3600
branch_paths = get_branch_paths()
results: Dict[str, Any] = {}
total_files = 0
active_count = 0
for branch_info in branch_paths:
name = branch_info["name"]
path = branch_info["path"]
activity = scan_branch_activity(name, path, since)
results[name] = activity
if activity["total_files"] > 0:
active_count += 1
total_files += activity["total_files"]
return {
"scan_time": datetime.now().isoformat(),
"time_window_hours": round(time_window_hours, 2),
"branches_scanned": len(branch_paths),
"branches_with_activity": active_count,
"total_files_modified": total_files,
"branches": results,
}
if __name__ == "__main__":
# Simple test
print("Testing activity_collector...")
print(f"Registry path: {REGISTRY_PATH}")
print(f"Registry exists: {REGISTRY_PATH.exists()}")
branches = get_branch_paths()
print(f"Found {len(branches)} branches")
if branches:
# Test scanning one branch
first = branches[0]
print(f"\nScanning {first['name']}...")
activity = scan_branch_activity(first['name'], first['path'])
print(f" Code files: {len(activity['code_files'])}")
print(f" Memory files: {len(activity['memory_files'])}")
print(f" Last activity: {activity['last_activity']}")
@@ -0,0 +1,423 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: memory_health.py - Branch Memory Health Checker
# Date: 2026-01-30
# Version: 0.1.0
# Category: daemon/handlers/monitoring
#
# CHANGELOG (Max 5 entries):
# - v0.1.0 (2026-01-30): Initial implementation - FPLAN-0266 Phase 1
#
# CODE STANDARDS:
# - Handler implements checking logic, module orchestrates
# - NO Prax logger in handlers (handlers don't log)
# - Uses pathlib for all path operations
# - Standard library only
# =============================================
"""
Branch Memory Health Checker Handler
Validates memory file existence, structure, and freshness for branches.
Provides health status reporting for the Branch Activity Monitoring System.
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any
# Health status constants
STATUS_OK = "OK"
STATUS_WARNING = "WARNING"
STATUS_RED = "RED"
# Required memory files (branch cannot function properly without these)
REQUIRED_FILES = ["local.json", "README.md"]
# Optional memory files (nice to have, warning if missing)
OPTIONAL_FILES = ["observations.json", "id.json"]
# Freshness thresholds (in days)
FRESHNESS_WARNING_DAYS = 7
FRESHNESS_RED_DAYS = 30
def check_memory_files_exist(
branch_path: str,
branch_name: str
) -> Dict[str, Any]:
"""
Check if required memory files exist for a branch.
Required files:
- [BRANCH].local.json
- README.md
Optional files:
- [BRANCH].observations.json
- [BRANCH].id.json
- DASHBOARD.local.json
Args:
branch_path: Absolute path to the branch directory.
branch_name: Name of the branch (uppercase, e.g., "DRONE").
Returns:
Dict with structure:
{
"required": {"filename": bool, ...},
"optional": {"filename": bool, ...},
"missing_required": [str],
"missing_optional": [str],
"all_required_present": bool
}
"""
directory = Path(branch_path)
# Build expected file paths
required_checks = {
f"{branch_name}.local.json": directory / f"{branch_name}.local.json",
"README.md": directory / "README.md",
}
optional_checks = {
f"{branch_name}.observations.json": directory / f"{branch_name}.observations.json",
f"{branch_name}.id.json": directory / f"{branch_name}.id.json",
"DASHBOARD.local.json": directory / "DASHBOARD.local.json",
}
# Check required files
required_results = {}
missing_required = []
for name, path in required_checks.items():
exists = path.exists() and path.is_file()
required_results[name] = exists
if not exists:
missing_required.append(name)
# Check optional files
optional_results = {}
missing_optional = []
for name, path in optional_checks.items():
exists = path.exists() and path.is_file()
optional_results[name] = exists
if not exists:
missing_optional.append(name)
return {
"required": required_results,
"optional": optional_results,
"missing_required": missing_required,
"missing_optional": missing_optional,
"all_required_present": len(missing_required) == 0,
}
def validate_memory_structure(
file_path: str
) -> Dict[str, Any]:
"""
Validate memory file structure (check metadata exists, check limits field).
Validates that .local.json and .observations.json files have proper structure:
- document_metadata section exists
- limits field exists within metadata
- Basic required fields present
Args:
file_path: Absolute path to the memory file.
Returns:
Dict with structure:
{
"valid": bool,
"has_metadata": bool,
"has_limits": bool,
"issues": [str],
"metadata_fields": [str] (if metadata exists)
}
"""
path = Path(file_path)
if not path.exists():
return {
"valid": False,
"has_metadata": False,
"has_limits": False,
"issues": ["File does not exist"],
"metadata_fields": [],
}
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except json.JSONDecodeError as e:
return {
"valid": False,
"has_metadata": False,
"has_limits": False,
"issues": [f"Invalid JSON: {str(e)}"],
"metadata_fields": [],
}
except OSError as e:
return {
"valid": False,
"has_metadata": False,
"has_limits": False,
"issues": [f"Cannot read file: {str(e)}"],
"metadata_fields": [],
}
issues = []
# Check for metadata section
# Memory files use either "document_metadata" or "metadata"
has_metadata = False
metadata_fields: List[str] = []
metadata_section = None
if "document_metadata" in data:
has_metadata = True
metadata_section = data["document_metadata"]
elif "metadata" in data:
has_metadata = True
metadata_section = data["metadata"]
if not has_metadata:
issues.append("No metadata section found (expected 'document_metadata' or 'metadata')")
# Check limits field
has_limits = False
if metadata_section and isinstance(metadata_section, dict):
metadata_fields = list(metadata_section.keys())
if "limits" in metadata_section:
has_limits = True
else:
issues.append("No 'limits' field in metadata")
# Overall validity
valid = has_metadata and len(issues) == 0
return {
"valid": valid,
"has_metadata": has_metadata,
"has_limits": has_limits,
"issues": issues,
"metadata_fields": metadata_fields,
}
def check_freshness(
file_path: str,
warning_days: int = FRESHNESS_WARNING_DAYS,
red_days: int = FRESHNESS_RED_DAYS
) -> Dict[str, Any]:
"""
Check when a file was last modified and determine freshness status.
Args:
file_path: Absolute path to the file.
warning_days: Days after which status becomes WARNING.
red_days: Days after which status becomes RED.
Returns:
Dict with structure:
{
"exists": bool,
"last_modified": str (ISO format) or None,
"days_ago": float or None,
"status": "OK" | "WARNING" | "RED",
"message": str
}
"""
path = Path(file_path)
if not path.exists():
return {
"exists": False,
"last_modified": None,
"days_ago": None,
"status": STATUS_RED,
"message": "File does not exist",
}
try:
mtime = datetime.fromtimestamp(path.stat().st_mtime)
days_ago = (datetime.now() - mtime).total_seconds() / 86400
if days_ago > red_days:
status = STATUS_RED
message = f"Not modified in {int(days_ago)} days (threshold: {red_days})"
elif days_ago > warning_days:
status = STATUS_WARNING
message = f"Not modified in {int(days_ago)} days (threshold: {warning_days})"
else:
status = STATUS_OK
message = f"Modified {days_ago:.1f} days ago"
return {
"exists": True,
"last_modified": mtime.isoformat(),
"days_ago": round(days_ago, 2),
"status": status,
"message": message,
}
except OSError as e:
return {
"exists": True,
"last_modified": None,
"days_ago": None,
"status": STATUS_RED,
"message": f"Cannot read file stats: {str(e)}",
}
def get_memory_health_status(
branch_path: str,
branch_name: str
) -> Dict[str, Any]:
"""
Get comprehensive memory health status for a branch.
Combines file existence, structure validation, and freshness checks
into an overall health assessment.
Health Status Levels:
- OK: All required files present, valid structure, recent activity
- WARNING: Missing optional files or stale (7+ days)
- RED: Missing required files or very stale (30+ days)
Args:
branch_path: Absolute path to the branch directory.
branch_name: Name of the branch (uppercase, e.g., "DRONE").
Returns:
Dict with structure:
{
"branch_name": str,
"branch_path": str,
"overall_status": "OK" | "WARNING" | "RED",
"file_check": {file existence results},
"structure_checks": {filename: validation result},
"freshness_checks": {filename: freshness result},
"issues": [str],
"check_time": str
}
"""
directory = Path(branch_path)
issues: List[str] = []
# Step 1: Check file existence
file_check = check_memory_files_exist(branch_path, branch_name)
if not file_check["all_required_present"]:
for missing in file_check["missing_required"]:
issues.append(f"Missing required file: {missing}")
for missing in file_check["missing_optional"]:
issues.append(f"Missing optional file: {missing}")
# Step 2: Validate structure of existing memory files
structure_checks = {}
local_file = directory / f"{branch_name}.local.json"
obs_file = directory / f"{branch_name}.observations.json"
if local_file.exists():
local_validation = validate_memory_structure(str(local_file))
structure_checks[f"{branch_name}.local.json"] = local_validation
if not local_validation["valid"]:
for issue in local_validation["issues"]:
issues.append(f"{branch_name}.local.json: {issue}")
if obs_file.exists():
obs_validation = validate_memory_structure(str(obs_file))
structure_checks[f"{branch_name}.observations.json"] = obs_validation
if not obs_validation["valid"]:
for issue in obs_validation["issues"]:
issues.append(f"{branch_name}.observations.json: {issue}")
# Step 3: Check freshness
freshness_checks = {}
files_to_check = [
(f"{branch_name}.local.json", local_file),
("README.md", directory / "README.md"),
]
worst_freshness = STATUS_OK
for name, path in files_to_check:
if path.exists():
freshness = check_freshness(str(path))
freshness_checks[name] = freshness
# Track worst freshness status
if freshness["status"] == STATUS_RED:
worst_freshness = STATUS_RED
elif freshness["status"] == STATUS_WARNING and worst_freshness != STATUS_RED:
worst_freshness = STATUS_WARNING
# Step 4: Determine overall status
if not file_check["all_required_present"]:
overall_status = STATUS_RED
elif worst_freshness == STATUS_RED:
overall_status = STATUS_RED
elif file_check["missing_optional"] or worst_freshness == STATUS_WARNING:
overall_status = STATUS_WARNING
else:
overall_status = STATUS_OK
# Filter out structure check issues from issues list for WARNING-only items
has_structure_issues = any(
not check.get("valid", True)
for check in structure_checks.values()
)
if has_structure_issues and overall_status == STATUS_OK:
overall_status = STATUS_WARNING
return {
"branch_name": branch_name,
"branch_path": branch_path,
"overall_status": overall_status,
"file_check": file_check,
"structure_checks": structure_checks,
"freshness_checks": freshness_checks,
"issues": issues,
"check_time": datetime.now().isoformat(),
}
if __name__ == "__main__":
# Simple test
print("Testing memory_health...")
# Test with a sample branch path
test_path = "."
test_name = "EXAMPLE"
print(f"\nChecking {test_name} at {test_path}")
# Test file existence
existence = check_memory_files_exist(test_path, test_name)
print(f" Required files present: {existence['all_required_present']}")
print(f" Missing required: {existence['missing_required']}")
print(f" Missing optional: {existence['missing_optional']}")
# Test structure validation
local_path = f"{test_path}/{test_name}.local.json"
structure = validate_memory_structure(local_path)
print(f" Structure valid: {structure['valid']}")
print(f" Has metadata: {structure['has_metadata']}")
print(f" Has limits: {structure['has_limits']}")
# Test freshness
freshness = check_freshness(local_path)
print(f" Freshness: {freshness['status']} ({freshness['message']})")
# Test overall health
health = get_memory_health_status(test_path, test_name)
print(f"\n Overall status: {health['overall_status']}")
print(f" Issues: {len(health['issues'])}")
for issue in health['issues'][:5]:
print(f" - {issue}")
@@ -0,0 +1,374 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: red_flag_detector.py - Branch Red Flag Detection Engine
# Date: 2026-01-30
# Version: 0.1.0
# Category: daemon/handlers/monitoring
#
# CHANGELOG (Max 5 entries):
# - v0.1.0 (2026-01-30): Initial implementation - FPLAN-0266 Phase 2
#
# CODE STANDARDS:
# - Handler implements detection logic, module orchestrates
# - NO Prax logger in handlers (handlers don't log)
# - Uses pathlib for all path operations
# - Standard library only
# =============================================
"""
Branch Red Flag Detection Engine
Detects presence violations: branches that modified code but did NOT update their memory files.
A RED FLAG indicates a branch did work but didn't maintain their presence (memory files).
Detection Logic:
- If code files were modified within the time window
- AND memory files were NOT modified (or modified BEFORE the code changes)
- This is a RED FLAG violation
OK Conditions:
- No code changes in time window (nothing to update)
- Memory updated MORE RECENTLY than code (presence maintained)
- Memory updated WITHIN threshold_hours of code changes
"""
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from . import activity_collector
# Status constants
STATUS_RED_FLAG = "RED_FLAG"
STATUS_OK = "OK"
STATUS_NO_ACTIVITY = "NO_ACTIVITY"
STATUS_ERROR = "ERROR"
def _parse_iso_datetime(iso_string: str) -> Optional[datetime]:
"""
Parse an ISO format datetime string to datetime object.
Args:
iso_string: ISO format datetime string (e.g., "2026-01-30T10:30:00.123456")
Returns:
datetime object or None if parsing fails.
"""
if not iso_string:
return None
try:
# Handle both with and without microseconds
if '.' in iso_string:
return datetime.fromisoformat(iso_string)
else:
return datetime.fromisoformat(iso_string)
except (ValueError, TypeError):
return None
def _get_most_recent_mtime(files: List[Dict[str, Any]]) -> Optional[datetime]:
"""
Get the most recent modification time from a list of file entries.
Args:
files: List of file dicts with 'mtime' key (ISO format string).
Returns:
Most recent datetime or None if list is empty or no valid times.
"""
if not files:
return None
mtimes = []
for f in files:
mtime_str = f.get("mtime")
if mtime_str:
dt = _parse_iso_datetime(mtime_str)
if dt:
mtimes.append(dt)
return max(mtimes) if mtimes else None
def get_branch_status(
branch_name: str,
branch_path: str,
since_timestamp: Optional[datetime] = None,
threshold_hours: float = 2.0
) -> Dict[str, Any]:
"""
Check a single branch for red flag violations.
A RED FLAG occurs when:
- Code files were modified since the timestamp
- Memory files were NOT modified, OR were modified BEFORE the latest code change
(with a grace period of threshold_hours)
Args:
branch_name: Name of the branch (uppercase, e.g., "DRONE").
branch_path: Absolute path to the branch directory.
since_timestamp: Only consider changes since this time.
Defaults to last 24 hours.
threshold_hours: Grace period in hours. Memory must be updated within
this many hours AFTER the latest code change.
Returns:
Dict with structure:
{
"branch_name": str,
"branch_path": str,
"status": "RED_FLAG" | "OK" | "NO_ACTIVITY" | "ERROR",
"code_changes": [{"file": str, "mtime": str}],
"code_change_count": int,
"latest_code_change": str (ISO) or None,
"memory_files_modified": [{"file": str, "mtime": str}],
"memory_last_update": str (ISO) or None,
"hours_since_code": float or None,
"threshold_hours": float,
"reason": str,
"check_time": str
}
"""
# Default time window: last 24 hours
if since_timestamp is None:
since_timestamp = datetime.now() - timedelta(hours=24)
result = {
"branch_name": branch_name,
"branch_path": branch_path,
"status": STATUS_OK,
"code_changes": [],
"code_change_count": 0,
"latest_code_change": None,
"memory_files_modified": [],
"memory_last_update": None,
"hours_since_code": None,
"threshold_hours": threshold_hours,
"reason": "",
"check_time": datetime.now().isoformat(),
}
try:
# Get activity data from the collector
activity = activity_collector.scan_branch_activity(
branch_name, branch_path, since_timestamp
)
except Exception as e:
result["status"] = STATUS_ERROR
result["reason"] = f"Failed to scan branch: {str(e)}"
return result
# Extract code file changes
code_files = activity.get("code_files", [])
memory_files = activity.get("memory_files", [])
# Format code changes for output
result["code_changes"] = [
{"file": f.get("name", ""), "mtime": f.get("mtime", "")}
for f in code_files
]
result["code_change_count"] = len(code_files)
# Format memory file modifications for output
result["memory_files_modified"] = [
{"file": f.get("name", ""), "mtime": f.get("mtime", "")}
for f in memory_files
]
# Get most recent times
latest_code = _get_most_recent_mtime(code_files)
latest_memory = _get_most_recent_mtime(memory_files)
if latest_code:
result["latest_code_change"] = latest_code.isoformat()
if latest_memory:
result["memory_last_update"] = latest_memory.isoformat()
# Decision logic
# Case 1: No code changes - OK (nothing to update)
if not code_files:
result["status"] = STATUS_NO_ACTIVITY
result["reason"] = "No code changes in time window"
return result
# Case 2: Code changed, check if memory was updated appropriately
if latest_code:
if latest_memory:
# Calculate how long after code the memory was updated
time_diff = latest_memory - latest_code
hours_diff = time_diff.total_seconds() / 3600
if latest_memory >= latest_code:
# Memory was updated at same time or after code - OK
result["status"] = STATUS_OK
result["hours_since_code"] = round(hours_diff, 2)
result["reason"] = f"Memory updated {abs(hours_diff):.1f}h after code changes"
elif abs(hours_diff) <= threshold_hours:
# Memory was updated slightly before, but within threshold - OK
# This covers cases where memory was updated just before final code commit
result["status"] = STATUS_OK
result["hours_since_code"] = round(hours_diff, 2)
result["reason"] = f"Memory update within threshold ({abs(hours_diff):.1f}h before code)"
else:
# Memory was updated too long before code changes - RED FLAG
result["status"] = STATUS_RED_FLAG
hours_since = (datetime.now() - latest_code).total_seconds() / 3600
result["hours_since_code"] = round(hours_since, 2)
result["reason"] = f"Code changed but memory last updated {abs(hours_diff):.1f}h BEFORE code"
else:
# No memory files modified in time window - RED FLAG
result["status"] = STATUS_RED_FLAG
hours_since = (datetime.now() - latest_code).total_seconds() / 3600
result["hours_since_code"] = round(hours_since, 2)
result["reason"] = f"Code changed {hours_since:.1f}h ago but no memory updates in time window"
return result
def detect_red_flags(
since_timestamp: Optional[datetime] = None,
threshold_hours: float = 2.0
) -> List[Dict[str, Any]]:
"""
Detect red flag violations across ALL branches.
Scans all branches in the system and identifies those where code was modified
but memory files were not updated appropriately.
Args:
since_timestamp: Only consider changes since this time.
Defaults to last 24 hours.
threshold_hours: Grace period in hours for memory updates after code changes.
Returns:
List of branch status dicts (same structure as get_branch_status),
sorted with RED_FLAG violations first, then by branch name.
"""
# Default time window: last 24 hours
if since_timestamp is None:
since_timestamp = datetime.now() - timedelta(hours=24)
# Get all branch paths
branches = activity_collector.get_branch_paths()
results: List[Dict[str, Any]] = []
for branch_info in branches:
name = branch_info.get("name", "")
path = branch_info.get("path", "")
if not name or not path:
continue
status = get_branch_status(name, path, since_timestamp, threshold_hours)
results.append(status)
# Sort: RED_FLAG first, then by branch name
def sort_key(item: Dict[str, Any]) -> tuple:
status_order = {
STATUS_RED_FLAG: 0,
STATUS_ERROR: 1,
STATUS_OK: 2,
STATUS_NO_ACTIVITY: 3,
}
return (status_order.get(item.get("status", ""), 99), item.get("branch_name", ""))
results.sort(key=sort_key)
return results
def get_red_flag_summary(
since_timestamp: Optional[datetime] = None,
threshold_hours: float = 2.0
) -> Dict[str, Any]:
"""
Get a summary of red flag detection across all branches.
Args:
since_timestamp: Only consider changes since this time.
threshold_hours: Grace period in hours for memory updates.
Returns:
Dict with structure:
{
"scan_time": str,
"time_window_hours": float,
"threshold_hours": float,
"total_branches": int,
"red_flags": int,
"ok": int,
"no_activity": int,
"errors": int,
"violations": [{branch status for RED_FLAG only}],
"all_branches": [{all branch statuses}]
}
"""
if since_timestamp is None:
since_timestamp = datetime.now() - timedelta(hours=24)
time_window = (datetime.now() - since_timestamp).total_seconds() / 3600
all_results = detect_red_flags(since_timestamp, threshold_hours)
# Count by status
counts = {
STATUS_RED_FLAG: 0,
STATUS_OK: 0,
STATUS_NO_ACTIVITY: 0,
STATUS_ERROR: 0,
}
violations = []
for result in all_results:
status = result.get("status", "")
if status in counts:
counts[status] += 1
if status == STATUS_RED_FLAG:
violations.append(result)
return {
"scan_time": datetime.now().isoformat(),
"time_window_hours": round(time_window, 2),
"threshold_hours": threshold_hours,
"total_branches": len(all_results),
"red_flags": counts[STATUS_RED_FLAG],
"ok": counts[STATUS_OK],
"no_activity": counts[STATUS_NO_ACTIVITY],
"errors": counts[STATUS_ERROR],
"violations": violations,
"all_branches": all_results,
}
if __name__ == "__main__":
# Simple test
print("Testing red_flag_detector...")
print("=" * 60)
# Get summary
summary = get_red_flag_summary()
print(f"Scan time: {summary['scan_time']}")
print(f"Time window: {summary['time_window_hours']} hours")
print(f"Threshold: {summary['threshold_hours']} hours")
print(f"\nTotal branches: {summary['total_branches']}")
print(f" RED FLAGS: {summary['red_flags']}")
print(f" OK: {summary['ok']}")
print(f" No activity: {summary['no_activity']}")
print(f" Errors: {summary['errors']}")
if summary['violations']:
print(f"\n{'='*60}")
print("RED FLAG VIOLATIONS:")
print("=" * 60)
for v in summary['violations']:
print(f"\n Branch: {v['branch_name']}")
print(f" Status: {v['status']}")
print(f" Reason: {v['reason']}")
print(f" Code changes: {v['code_change_count']}")
print(f" Latest code change: {v['latest_code_change']}")
print(f" Memory last update: {v['memory_last_update']}")
else:
print("\nNo red flag violations detected.")
@@ -0,0 +1,38 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: __init__.py - Schedule Handlers Package
# Date: 2026-02-04
# Version: 1.0.0
# Category: assistant/handlers/schedule
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-04): Initial package setup
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No cross-branch imports, no Prax logger
# =============================================
"""
Schedule handlers for ASSISTANT's scheduled follow-ups system.
"""
from .task_registry import (
load_tasks,
save_tasks,
create_task,
delete_task,
get_due_tasks,
mark_completed,
parse_due_date
)
__all__ = [
'load_tasks',
'save_tasks',
'create_task',
'delete_task',
'get_due_tasks',
'mark_completed',
'parse_due_date'
]
@@ -0,0 +1,170 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: assistant_notifier.py - Daemon Bot Telegram Notifications
# Date: 2026-02-15
# Version: 1.0.0
# Category: daemon/handlers/schedule
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-15): Initial implementation - daemon bot notifications
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No cross-branch imports, no Prax logger
# - stdlib only (urllib, json) - runs under /usr/bin/python3
# =============================================
"""
Handler for sending Telegram notifications via the daemon bot.
Patrick's direct line to daemon. Uses the dedicated daemon bot
(separate from scheduler and bridge bots) to notify Patrick of
wake-up events, email reports, and errors.
"""
import os
import json
from datetime import datetime
from pathlib import Path
from typing import Dict
from urllib.request import Request, urlopen
from urllib.error import URLError
# =============================================
# CONSTANTS
# =============================================
CONFIG_PATH = Path(os.environ.get('AIPASS_DAEMON_CONFIG', Path.home() / '.aipass' / 'daemon_config.json'))
# =============================================
# CONFIG
# =============================================
def load_config() -> Dict[str, str]:
"""
Load daemon bot config from daemon_config.json.
Returns:
Dict with 'bot_token' and 'chat_id' keys
Raises:
FileNotFoundError: If config file is missing
KeyError: If required keys are absent
"""
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
return {
"bot_token": raw["telegram_bot_token"],
"chat_id": raw["telegram_chat_id"],
}
# =============================================
# SEND
# =============================================
def send_notification(message: str) -> bool:
"""
Send a message to Patrick via the daemon bot.
Args:
message: Text to send (plain text, supports emoji)
Returns:
True if sent successfully, False otherwise
"""
try:
config = load_config()
except (FileNotFoundError, KeyError, json.JSONDecodeError) as e:
print(f"[daemon_notifier] Config error: {e}")
return False
url = f"https://api.telegram.org/bot{config['bot_token']}/sendMessage"
payload = {
"chat_id": config["chat_id"],
"text": message,
}
data = json.dumps(payload).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
if result.get("ok"):
return True
print(f"[daemon_notifier] API error: {result.get('description')}")
return False
except URLError as e:
print(f"[daemon_notifier] Send failed: {e}")
return False
except Exception as e:
print(f"[daemon_notifier] Unexpected error: {e}")
return False
# =============================================
# NOTIFICATION HELPERS
# =============================================
def notify_wakeup() -> bool:
"""
Notify that daemon is waking up.
Returns:
True if notification sent, False otherwise
"""
now = datetime.now().strftime("%H:%M:%S")
message = "\U0001f916 Daemon waking up at " + now
return send_notification(message)
def notify_report(summary: str) -> bool:
"""
Send daemon's wake-up report.
Args:
summary: Report content (email counts, listings, etc.)
Returns:
True if notification sent, False otherwise
"""
message = "\U0001f4cb Daemon Report:\n" + summary
return send_notification(message)
def notify_error(error: str) -> bool:
"""
Notify that an error occurred during wake-up.
Args:
error: Error description
Returns:
True if notification sent, False otherwise
"""
message = "\u274c Daemon Error:\n" + error
return send_notification(message)
# =============================================
# MAIN - Testing
# =============================================
if __name__ == "__main__":
print("assistant_notifier.py - manual test")
print(f"Config path: {CONFIG_PATH}")
try:
cfg = load_config()
print(f"Bot token: {cfg['bot_token'][:12]}...")
print(f"Chat ID: {cfg['chat_id']}")
except Exception as e:
print(f"Config load failed: {e}")
print("\nSending test notification...")
ok = send_notification("Test from assistant_notifier.py handler")
print(f"Result: {'OK' if ok else 'FAILED'}")
@@ -0,0 +1,449 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: task_registry.py - DAEMON Scheduled Tasks Registry
# Date: 2026-02-04
# Version: 1.0.0
# Category: daemon/handlers/schedule
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-04): Initial implementation - task storage and operations
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No cross-branch imports, no Prax logger
# =============================================
"""
Handler for scheduled task storage and operations.
Fire-and-forget follow-up system for DAEMON.
Tasks are stored in daemon_json/schedule.json and processed
when their due date arrives.
"""
import json
import uuid
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
import re
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[4] # src/aipass/daemon/
SCHEDULE_JSON_PATH = _DAEMON_ROOT / "daemon_json" / "schedule.json"
DEFAULT_SCHEDULE_DATA: Dict[str, Any] = {
"tasks": []
}
# =============================================
# JSON FILE OPERATIONS
# =============================================
def _ensure_json_exists() -> None:
"""Ensure schedule.json exists, create with defaults if missing."""
SCHEDULE_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
if not SCHEDULE_JSON_PATH.exists():
with open(SCHEDULE_JSON_PATH, 'w', encoding='utf-8') as f:
json.dump(DEFAULT_SCHEDULE_DATA, f, indent=2, ensure_ascii=False)
def load_tasks() -> List[Dict[str, Any]]:
"""
Load all tasks from schedule.json.
Returns:
List of task dictionaries
"""
_ensure_json_exists()
try:
with open(SCHEDULE_JSON_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("tasks", [])
except (json.JSONDecodeError, IOError):
return []
def save_tasks(tasks: List[Dict[str, Any]]) -> bool:
"""
Save tasks to schedule.json.
Args:
tasks: List of task dictionaries to save
Returns:
True if successful, False otherwise
"""
_ensure_json_exists()
try:
data = {"tasks": tasks}
with open(SCHEDULE_JSON_PATH, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
except IOError:
return False
# =============================================
# DATE PARSING
# =============================================
def parse_due_date(date_str: str) -> str:
"""
Parse various date formats to ISO 8601 date string.
Supports:
- "7d" -> 7 days from now
- "1w" -> 1 week from now
- "2w" -> 2 weeks from now
- "2026-02-11" -> exact date (ISO 8601)
Args:
date_str: Date string in supported format
Returns:
ISO 8601 date string (YYYY-MM-DD)
Raises:
ValueError: If date format is invalid
"""
date_str = date_str.strip()
today = datetime.now().date()
# Check for relative day format: "7d", "14d", etc.
day_match = re.match(r'^(\d+)d$', date_str, re.IGNORECASE)
if day_match:
days = int(day_match.group(1))
future_date = today + timedelta(days=days)
return future_date.isoformat()
# Check for relative week format: "1w", "2w", etc.
week_match = re.match(r'^(\d+)w$', date_str, re.IGNORECASE)
if week_match:
weeks = int(week_match.group(1))
future_date = today + timedelta(weeks=weeks)
return future_date.isoformat()
# Check for ISO 8601 date format: "2026-02-11"
iso_match = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', date_str)
if iso_match:
try:
# Validate it's a real date
year = int(iso_match.group(1))
month = int(iso_match.group(2))
day = int(iso_match.group(3))
parsed_date = datetime(year, month, day).date()
return parsed_date.isoformat()
except ValueError as e:
raise ValueError(f"Invalid date: {date_str}") from e
raise ValueError(
f"Invalid date format: '{date_str}'. "
"Use '7d' (days), '1w' (weeks), or 'YYYY-MM-DD' (ISO date)"
)
# =============================================
# TASK OPERATIONS
# =============================================
def _generate_task_id() -> str:
"""Generate 8-character UUID for task ID."""
return uuid.uuid4().hex[:8]
def create_task(
task: str,
due_date: str,
recipient: str,
message: str
) -> Dict[str, Any]:
"""
Create a new scheduled task.
Args:
task: Brief description of the task/follow-up
due_date: When to trigger (supports "7d", "1w", "YYYY-MM-DD")
recipient: Target branch (e.g., "@dev_central")
message: Message to deliver when due
Returns:
Created task dictionary
Raises:
ValueError: If due_date format is invalid
"""
parsed_due = parse_due_date(due_date)
new_task: Dict[str, Any] = {
"id": _generate_task_id(),
"created": datetime.now().date().isoformat(),
"due_date": parsed_due,
"task": task,
"recipient": recipient,
"message": message,
"status": "pending"
}
tasks = load_tasks()
tasks.append(new_task)
save_tasks(tasks)
return new_task
def delete_task(task_id: str) -> bool:
"""
Delete a task by ID.
Args:
task_id: 8-character task ID
Returns:
True if task was found and deleted, False otherwise
"""
tasks = load_tasks()
original_count = len(tasks)
tasks = [t for t in tasks if t.get("id") != task_id]
if len(tasks) < original_count:
save_tasks(tasks)
return True
return False
def get_due_tasks() -> List[Dict[str, Any]]:
"""
Get all tasks that are due (due_date <= today).
Only returns tasks with status 'pending' - excludes 'dispatching' and 'completed'.
Returns:
List of tasks that are due for processing
"""
tasks = load_tasks()
today = datetime.now().date().isoformat()
due_tasks = [
t for t in tasks
if t.get("status") == "pending" and t.get("due_date", "") <= today
]
return due_tasks
def mark_dispatching(task_id: str) -> bool:
"""
Mark a task as currently being dispatched.
Prevents re-dispatch while email is being sent.
Args:
task_id: 8-character task ID
Returns:
True if task was found and marked, False otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
task["status"] = "dispatching"
task["dispatch_started"] = datetime.now().isoformat()
save_tasks(tasks)
return True
return False
def mark_pending(task_id: str) -> bool:
"""
Reset a task to pending status (for retry after failed dispatch).
Args:
task_id: 8-character task ID
Returns:
True if task was found and reset, False otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
task["status"] = "pending"
task.pop("dispatch_started", None)
save_tasks(tasks)
return True
return False
def recover_stale_dispatches(max_age_minutes: int = 5) -> int:
"""
Reset tasks stuck in 'dispatching' status for too long.
Called before processing to recover from crashed dispatches.
Args:
max_age_minutes: Maximum time a task can be in dispatching status
Returns:
Number of tasks recovered
"""
tasks = load_tasks()
recovered = 0
cutoff = datetime.now() - timedelta(minutes=max_age_minutes)
for task in tasks:
if task.get("status") == "dispatching":
started = task.get("dispatch_started")
if started:
try:
start_time = datetime.fromisoformat(started)
if start_time < cutoff:
task["status"] = "pending"
task.pop("dispatch_started", None)
recovered += 1
except ValueError:
# Invalid timestamp, reset anyway
task["status"] = "pending"
task.pop("dispatch_started", None)
recovered += 1
if recovered:
save_tasks(tasks)
return recovered
def mark_completed(task_id: str) -> bool:
"""
Mark a task as completed.
Args:
task_id: 8-character task ID
Returns:
True if task was found and marked, False otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
task["status"] = "completed"
task["completed_date"] = datetime.now().date().isoformat()
save_tasks(tasks)
return True
return False
def get_task_by_id(task_id: str) -> Optional[Dict[str, Any]]:
"""
Get a single task by ID.
Args:
task_id: 8-character task ID
Returns:
Task dictionary if found, None otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
return task
return None
def get_pending_tasks() -> List[Dict[str, Any]]:
"""
Get all pending tasks (not yet due or completed).
Returns:
List of pending tasks
"""
tasks = load_tasks()
return [t for t in tasks if t.get("status") == "pending"]
# =============================================
# MAIN - Testing
# =============================================
if __name__ == "__main__":
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
console.print()
console.print(Panel.fit(
"[bold cyan]TASK REGISTRY - Handler Test[/bold cyan]",
border_style="bright_blue"
))
console.print()
# Test date parsing
console.print("[yellow]Testing date parsing:[/yellow]")
test_dates = ["7d", "1w", "2w", "2026-03-15"]
for d in test_dates:
try:
result = parse_due_date(d)
console.print(f" {d} -> {result}")
except ValueError as e:
console.print(f" {d} -> [red]ERROR: {e}[/red]")
# Test invalid date
try:
parse_due_date("invalid")
except ValueError as e:
console.print(f" invalid -> [green]Correctly raised: {e}[/green]")
console.print()
console.print("[yellow]Testing task creation:[/yellow]")
# Create a test task
test_task = create_task(
task="Test backup health check",
due_date="7d",
recipient="@dev_central",
message="Please verify backup systems are healthy"
)
console.print(f" Created task: {test_task['id']}")
console.print(f" Due: {test_task['due_date']}")
# Show all tasks
console.print()
console.print("[yellow]Current tasks:[/yellow]")
all_tasks = load_tasks()
table = Table(show_header=True)
table.add_column("ID", style="cyan")
table.add_column("Task", style="white")
table.add_column("Due", style="yellow")
table.add_column("Status", style="green")
for t in all_tasks:
table.add_row(
t.get("id", "?"),
t.get("task", "?")[:30],
t.get("due_date", "?"),
t.get("status", "?")
)
console.print(table)
console.print()
console.print(f"[dim]Schedule file: {SCHEDULE_JSON_PATH}[/dim]")
console.print()
@@ -0,0 +1,174 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: telegram_notifier.py - DAEMON Scheduler Telegram Notifications
# Date: 2026-02-15
# Version: 1.0.0
# Category: daemon/handlers/schedule
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-15): Initial implementation - scheduler bot notifications
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No cross-branch imports, no Prax logger
# =============================================
"""
Handler for sending Telegram notifications via the scheduler bot.
Reusable notification layer for scheduled events. Uses the dedicated
scheduler bot (separate from bridge bot) to notify Patrick of
triggered events, completions, and errors.
"""
import os
import json
from datetime import datetime
from pathlib import Path
from typing import Dict
from urllib.request import Request, urlopen
from urllib.error import URLError
# =============================================
# CONSTANTS
# =============================================
CONFIG_PATH = Path(os.environ.get('AIPASS_DAEMON_CONFIG', Path.home() / '.aipass' / 'daemon_config.json'))
# =============================================
# CONFIG
# =============================================
def load_config() -> Dict[str, str]:
"""
Load scheduler bot config from daemon_config.json.
Returns:
Dict with 'bot_token' and 'chat_id' keys
Raises:
FileNotFoundError: If config file is missing
KeyError: If required keys are absent
"""
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
return {
"bot_token": raw["telegram_bot_token"],
"chat_id": raw["telegram_chat_id"],
}
# =============================================
# SEND
# =============================================
def send_notification(message: str) -> bool:
"""
Send a message to Patrick via the scheduler bot.
Args:
message: Text to send (plain text, supports emoji)
Returns:
True if sent successfully, False otherwise
"""
try:
config = load_config()
except (FileNotFoundError, KeyError, json.JSONDecodeError) as e:
print(f"[telegram_notifier] Config error: {e}")
return False
url = f"https://api.telegram.org/bot{config['bot_token']}/sendMessage"
payload = {
"chat_id": config["chat_id"],
"text": message,
}
data = json.dumps(payload).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
if result.get("ok"):
return True
print(f"[telegram_notifier] API error: {result.get('description')}")
return False
except URLError as e:
print(f"[telegram_notifier] Send failed: {e}")
return False
except Exception as e:
print(f"[telegram_notifier] Unexpected error: {e}")
return False
# =============================================
# NOTIFICATION HELPERS
# =============================================
def notify_triggered(event_name: str) -> bool:
"""
Notify that a scheduled event was triggered.
Args:
event_name: Name of the event that fired
Returns:
True if notification sent, False otherwise
"""
now = datetime.now().strftime("%H:%M:%S")
message = f"\U0001f514 Scheduler: {event_name} triggered at {now}"
return send_notification(message)
def notify_complete(event_name: str, summary: str) -> bool:
"""
Notify that a scheduled event completed successfully.
Args:
event_name: Name of the event that completed
summary: Brief summary of what happened
Returns:
True if notification sent, False otherwise
"""
message = f"\u2705 Scheduler: {event_name} complete\n{summary}"
return send_notification(message)
def notify_error(event_name: str, error: str) -> bool:
"""
Notify that a scheduled event failed.
Args:
event_name: Name of the event that failed
error: Error description
Returns:
True if notification sent, False otherwise
"""
message = f"\u274c Scheduler: {event_name} failed\n{error}"
return send_notification(message)
# =============================================
# MAIN - Testing
# =============================================
if __name__ == "__main__":
print("telegram_notifier.py - manual test")
print(f"Config path: {CONFIG_PATH}")
try:
cfg = load_config()
print(f"Bot token: {cfg['bot_token'][:12]}...")
print(f"Chat ID: {cfg['chat_id']}")
except Exception as e:
print(f"Config load failed: {e}")
print("\nSending test notification...")
ok = send_notification("Test from telegram_notifier.py handler")
print(f"Result: {'OK' if ok else 'FAILED'}")
+51
View File
@@ -0,0 +1,51 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: assistant_chat.py - Daemon Bot Telegram Launcher
# Date: 2026-02-15
# Version: 2.0.0
# Category: daemon/handlers/telegram
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2026-02-15): Thin launcher - delegates to shared direct_chat.py
# - v1.1.0 (2026-02-15): Photo/document support (archived)
# - v1.0.0 (2026-02-15): Initial long-polling + tmux bridge (archived)
#
# CODE STANDARDS:
# - Thin launcher only - all logic lives in direct_chat.py
# - External AI imports made optional
# =============================================
"""
Daemon Bot Telegram Launcher (@aipass_assistant_bot)
Thin wrapper around the shared direct_chat module. All chat logic,
command handling, tmux management, and polling lives in direct_chat.py.
This file only provides the daemon-specific configuration.
"""
import sys
from pathlib import Path
try:
from api.apps.modules.telegram_chat import run_direct_chat
TELEGRAM_CHAT_AVAILABLE = True
except ImportError:
TELEGRAM_CHAT_AVAILABLE = False
run_direct_chat = None
_DAEMON_ROOT = Path(__file__).resolve().parents[4] # src/aipass/daemon/
if not TELEGRAM_CHAT_AVAILABLE:
print("[assistant_chat] telegram_chat module not available, exiting")
sys.exit(1)
sys.exit(run_direct_chat(
branch_name="daemon",
session_name="telegram-daemon",
config_path=Path.home() / ".aipass" / "assistant_bot_config.json",
work_dir=_DAEMON_ROOT,
log_dir=_DAEMON_ROOT / "logs",
data_dir=_DAEMON_ROOT / "daemon_json",
bot_name="AIPass Assistant Bot",
))
@@ -0,0 +1 @@
# Update handlers package
@@ -0,0 +1,112 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: data_loader.py - DAEMON Data Loading Handler
# Date: 2026-01-29
# Version: 1.0.0
# Category: daemon/handlers/update
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-01-29): Initial implementation - data loading for update digest
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No cross-branch imports, no Prax logger
# =============================================
"""
Handler for loading DAEMON data from inbox and local files.
"""
import json
from pathlib import Path
from typing import Dict, Any, List
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[4] # src/aipass/daemon/
INBOX_PATH = _DAEMON_ROOT / "ai_mail.local" / "inbox.json"
LOCAL_PATH = _DAEMON_ROOT / "DAEMON.local.json"
# =============================================
# DATA LOADING
# =============================================
def load_inbox() -> Dict[str, Any]:
"""Load inbox.json and return parsed data."""
if not INBOX_PATH.exists():
return {"messages": [], "total_messages": 0, "unread_count": 0}
try:
with open(INBOX_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return {"messages": [], "total_messages": 0, "unread_count": 0}
def load_local() -> Dict[str, Any]:
"""Load DAEMON.local.json and return parsed data."""
if not LOCAL_PATH.exists():
return {"sessions": [], "active_tasks": {}}
try:
with open(LOCAL_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return {"sessions": [], "active_tasks": {}}
# =============================================
# DIGEST ANALYSIS
# =============================================
def categorize_messages(messages: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
"""
Categorize inbox messages by status.
Returns:
Dict with keys: new, opened, actionable, informational
"""
categories: Dict[str, List[Dict[str, Any]]] = {
"new": [],
"opened": [],
"actionable": [],
"informational": []
}
for msg in messages:
status = msg.get("status", "new")
subject = msg.get("subject", "").upper()
if status == "new":
categories["new"].append(msg)
elif status == "opened":
categories["opened"].append(msg)
if any(kw in subject for kw in ["TASK:", "BUILD:", "FIX:", "PROPOSAL:", "REQUEST:"]):
categories["actionable"].append(msg)
elif any(kw in subject for kw in ["INFO", "RE:", "FYI", "NOTIFICATION"]):
categories["informational"].append(msg)
return categories
def get_session_summary(local_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extract session summary from local.json."""
sessions = local_data.get("sessions", [])
active_tasks = local_data.get("active_tasks", {})
return {
"total_sessions": len(sessions),
"today_focus": active_tasks.get("today_focus", "None"),
"recently_completed": active_tasks.get("recently_completed", []),
"latest_session": sessions[0] if sessions else None
}
def get_escalations(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Find messages that need escalation."""
return [m for m in messages if "BLOCKED" in m.get("subject", "").upper()
or "URGENT" in m.get("subject", "").upper()]
@@ -0,0 +1 @@
# JSON Templates package - Default JSON file templates
@@ -0,0 +1 @@
# Modules package - Branch-specific functionality modules
+527
View File
@@ -0,0 +1,527 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: actions.py - Action Registry CLI Module
# Date: 2026-03-02
# Version: 1.0.0
# Category: daemon/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-02): Initial creation - DPLAN-043
# * List, toggle, info, set reminder, set schedule
# * Plugin migration command
# * Formatted Rich console output
#
# CODE STANDARDS:
# - Seed pattern compliance - console.print() for all output
# - Thin orchestration - handlers implement logic
# =============================================
"""
CLI interface for the numbered action registry.
"""
# =============================================
# IMPORTS
# =============================================
import sys
from pathlib import Path
from typing import List
import logging
logger = logging.getLogger(__name__)
from rich.console import Console
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
def _success(text):
console.print(f"[green]OK:[/green] {text}")
def _error(text):
console.print(f"[red]ERROR:[/red] {text}")
from ..handlers.actions.actions_registry import (
list_actions,
get_action,
toggle_action,
delete_action,
create_action,
migrate_plugins,
next_due_str,
)
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "actions"
# =============================================
# OUTPUT FORMATTING
# =============================================
def _print_actions_table(actions: list) -> None:
"""Display formatted action list as a table."""
console.print()
_header("Action Registry")
console.print()
if not actions:
console.print("[dim]No actions registered. Run 'actions migrate' to import plugins.[/dim]")
console.print()
return
# Header row
console.print(
f" {'ID':<6} {'ON':<4} {'NAME':<24} {'TYPE':<10} "
f"{'TARGET':<16} {'SCHEDULE':<20} {'NEXT DUE':<16}"
)
console.print(" " + "-" * 96)
for action in actions:
action_id = action.get("id", "????")
enabled = "[green]ON[/green] " if action.get("enabled") else "[red]OFF[/red]"
name = action.get("name", "")[:22]
action_type = action.get("type", "")[:8]
target = action.get("target_branch", "")[:14]
schedule_type = action.get("schedule_type", "")
# Build schedule display
if schedule_type == "daily":
schedule_str = f"daily @ {action.get('time', '??:??')}"
elif schedule_type == "hourly":
m = action.get("time", "0")
schedule_str = f"hourly @ :{int(m):02d}"
elif schedule_type == "interval":
mins = action.get("interval_minutes", 0)
if mins >= 60:
schedule_str = f"every {mins // 60}h"
else:
schedule_str = f"every {mins}m"
elif schedule_type == "once":
schedule_str = f"once: {action.get('due_date', '?')}"
else:
schedule_str = schedule_type
next_due = next_due_str(action)
console.print(
f" {action_id:<6} {enabled:<4} {name:<24} {action_type:<10} "
f" {target:<16} {schedule_str:<20} {next_due:<16}"
)
console.print()
enabled_count = sum(1 for a in actions if a.get("enabled"))
console.print(f" [dim]Total: {len(actions)} actions ({enabled_count} enabled)[/dim]")
console.print()
def _print_action_detail(action: dict) -> None:
"""Display detailed view of a single action."""
console.print()
_header(f"Action {action['id']}: {action['name']}")
console.print()
fields = [
("ID", action.get("id")),
("Name", action.get("name")),
("Type", action.get("type")),
("Enabled", "[green]ON[/green]" if action.get("enabled") else "[red]OFF[/red]"),
("Schedule", action.get("schedule_type")),
("Time", action.get("time")),
("Interval", f"{action.get('interval_minutes')}m" if action.get("interval_minutes") else None),
("Due Date", action.get("due_date")),
("Target", action.get("target_branch")),
("Fresh", action.get("fresh")),
("Max Turns", action.get("max_turns")),
("Self Dispatch", action.get("self_dispatch")),
("Plugin File", action.get("plugin_file")),
("Last Run", action.get("last_run", "never")[:19] if action.get("last_run") else "never"),
("Next Run", next_due_str(action)),
("Created", action.get("created", "")[:19]),
("Completed", action.get("completed")),
]
for label, value in fields:
if value is None:
continue
console.print(f" [cyan]{label:<16}[/cyan] {value}")
# Show prompt (truncated for readability)
prompt = action.get("prompt", "")
if prompt:
console.print()
console.print(" [cyan]Prompt:[/cyan]")
# Show first 200 chars
display_prompt = prompt[:200]
if len(prompt) > 200:
display_prompt += "..."
for line in display_prompt.split("\n"):
console.print(f" [dim]{line}[/dim]")
console.print()
def _print_help() -> None:
"""Display help using Rich formatted output."""
console.print()
_header("Actions -- Numbered Action Registry")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" drone @daemon actions list")
console.print(" drone @daemon actions <id> info")
console.print(" drone @daemon actions <id> on")
console.print(" drone @daemon actions <id> off")
console.print(' drone @daemon actions set reminder <date> "message" [--to @branch]')
console.print(' drone @daemon actions set schedule @branch "prompt" <type> [time]')
console.print(" drone @daemon actions migrate")
console.print(" drone @daemon actions delete <id>")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" list List all registered actions with status")
console.print(" <id> info Show detailed view of a single action")
console.print(" <id> on Enable an action")
console.print(" <id> off Disable an action")
console.print(" set Create a new reminder or schedule")
console.print(" migrate Import existing plugins into registry")
console.print(" delete <id> Remove an action from the registry")
console.print()
console.print("[yellow]SET REMINDER:[/yellow]")
console.print(' set reminder 2026-03-11 "Check VERA progress"')
console.print(' set reminder 7d "Follow up on PR review" --to @flow')
console.print(" [dim]Date formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
console.print()
console.print("[yellow]SET SCHEDULE:[/yellow]")
console.print(' set schedule @seed "Run audit" daily 04:00')
console.print(' set schedule @vera "Heartbeat" interval 240')
console.print(' set schedule @flow "Check plans" hourly 30')
console.print(" [dim]Types: daily HH:MM, hourly MM, interval MINUTES[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" actions list # See all actions")
console.print(" actions 0003 off # Disable action 3")
console.print(" actions 0003 on # Re-enable it")
console.print(' actions set reminder 2026-03-11 "check VERA" # One-shot reminder')
console.print()
# =============================================
# SUBCOMMAND HANDLERS
# =============================================
def _handle_list(_args: List[str]) -> bool:
"""Handle 'actions list' subcommand."""
actions = list_actions()
_print_actions_table(actions)
return True
def _handle_toggle(action_id: str, enable: bool) -> bool:
"""Handle 'actions <id> on/off' subcommand."""
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return False
toggle_action(action_id, enable)
state = "enabled" if enable else "disabled"
_success(f"Action {action_id} ({action['name']}) {state}")
return True
def _handle_info(action_id: str) -> bool:
"""Handle 'actions <id> info' subcommand."""
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return False
_print_action_detail(action)
return True
def _handle_set_reminder(args: List[str]) -> bool:
"""Handle 'actions set reminder <date> "message" [--to @branch]'."""
if len(args) < 2:
_error('Usage: actions set reminder <date> "message" [--to @branch]')
return False
date_str = args[0]
message = args[1]
target_branch = "@dev_central" # Default reminder target
# Parse --to flag
if "--to" in args:
to_idx = args.index("--to")
if to_idx + 1 < len(args):
target_branch = args[to_idx + 1]
# Parse date
due_date = _parse_date(date_str)
if not due_date:
_error(f"Invalid date format: {date_str}")
console.print("[dim]Valid formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
return False
action = create_action(
name=message[:50],
action_type="reminder",
schedule_type="once",
target_branch=target_branch,
prompt=message,
due_date=due_date,
fresh=True,
max_turns=10,
enabled=True,
)
_success(f"Reminder created: {action['id']}")
console.print(f" [dim]Due:[/dim] {due_date}")
console.print(f" [dim]To:[/dim] {target_branch}")
console.print(f" [dim]Message:[/dim] {message[:60]}")
console.print()
return True
def _handle_set_schedule(args: List[str]) -> bool:
"""Handle 'actions set schedule @branch "prompt" <type> [time_spec]'."""
if len(args) < 3:
_error('Usage: actions set schedule @branch "prompt" <daily|hourly|interval> [time_spec]')
return False
target_branch = args[0]
prompt = args[1]
schedule_type = args[2]
time_val = None
interval_minutes = None
if schedule_type == "daily":
if len(args) < 4:
_error("Daily schedule requires time: actions set schedule @branch \"prompt\" daily HH:MM")
return False
time_val = args[3]
elif schedule_type == "hourly":
if len(args) < 4:
_error("Hourly schedule requires minute: actions set schedule @branch \"prompt\" hourly MM")
return False
time_val = args[3]
elif schedule_type == "interval":
if len(args) < 4:
_error("Interval schedule requires minutes: actions set schedule @branch \"prompt\" interval MINUTES")
return False
try:
interval_minutes = int(args[3])
except ValueError:
_error(f"Invalid interval minutes: {args[3]}")
return False
else:
_error(f"Unknown schedule type: {schedule_type}")
console.print("[dim]Valid types: daily, hourly, interval[/dim]")
return False
# Generate a name from the prompt
name = prompt[:50].replace(" ", "_").lower()
action = create_action(
name=name,
action_type="schedule",
schedule_type=schedule_type,
target_branch=target_branch,
prompt=prompt,
time=time_val,
interval_minutes=interval_minutes,
fresh=True,
max_turns=50,
enabled=True,
)
_success(f"Schedule created: {action['id']}")
console.print(f" [dim]Name:[/dim] {action['name']}")
console.print(f" [dim]Target:[/dim] {target_branch}")
console.print(f" [dim]Type:[/dim] {schedule_type}")
if time_val:
console.print(f" [dim]Time:[/dim] {time_val}")
if interval_minutes:
console.print(f" [dim]Every:[/dim] {interval_minutes} minutes")
console.print()
return True
def _handle_migrate(_args: List[str]) -> bool:
"""Handle 'actions migrate' -- import plugins into registry."""
console.print()
console.print("[dim]Scanning plugins/ for unregistered plugins...[/dim]")
count = migrate_plugins()
if count > 0:
_success(f"Migrated {count} plugin(s) into the action registry")
else:
console.print("[dim]All plugins already registered (or none found).[/dim]")
# Show the updated list
actions = list_actions()
_print_actions_table(actions)
return True
def _handle_delete(args: List[str]) -> bool:
"""Handle 'actions delete <id>'."""
if not args:
_error("Action ID required: actions delete <id>")
return False
action_id = args[0]
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return False
delete_action(action_id)
_success(f"Deleted action {action_id}: {action['name']}")
return True
# =============================================
# DATE PARSING
# =============================================
def _parse_date(date_str: str) -> str:
"""
Parse a date string into ISO format.
Supports: YYYY-MM-DD, 1d, 7d, 1w, 2w
Returns:
ISO date string or empty string on failure.
"""
from datetime import datetime, timedelta
date_str = date_str.strip()
# Relative dates
if date_str.endswith("d"):
try:
days = int(date_str[:-1])
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
except ValueError:
return ""
elif date_str.endswith("w"):
try:
weeks = int(date_str[:-1])
return (datetime.now() + timedelta(weeks=weeks)).strftime("%Y-%m-%d")
except ValueError:
return ""
# ISO date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
return ""
# =============================================
# ORCHESTRATION
# =============================================
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle 'actions' command and route to subcommands.
Args:
command: Command name (should be 'actions')
args: Command arguments
Returns:
True if handled, False otherwise
"""
if command != "actions":
return False
try:
# No args or help
if not args or args[0] in ['--help', '-h', 'help']:
_print_help()
return True
subcommand = args[0]
# Named subcommands
if subcommand == "list":
return _handle_list(args[1:])
elif subcommand == "migrate":
return _handle_migrate(args[1:])
elif subcommand == "delete":
return _handle_delete(args[1:])
elif subcommand == "set":
# actions set reminder ... / actions set schedule ...
if len(args) < 2:
_error("Usage: actions set <reminder|schedule> ...")
return False
set_type = args[1]
if set_type == "reminder":
return _handle_set_reminder(args[2:])
elif set_type == "schedule":
return _handle_set_schedule(args[2:])
else:
_error(f"Unknown set type: {set_type}. Use 'reminder' or 'schedule'.")
return False
# Check if first arg is an action ID (4-digit numeric)
if subcommand.isdigit() and len(subcommand) == 4:
action_id = subcommand
if len(args) < 2:
# Default to info
return _handle_info(action_id)
sub_action = args[1]
if sub_action == "on":
return _handle_toggle(action_id, True)
elif sub_action == "off":
return _handle_toggle(action_id, False)
elif sub_action == "info":
return _handle_info(action_id)
else:
_error(f"Unknown action command: {sub_action}. Use 'on', 'off', or 'info'.")
return False
_error(f"Unknown subcommand: {subcommand}")
console.print("[dim]Run 'actions --help' for available commands[/dim]")
return False
except Exception as e:
logger.error("[actions] Error in actions command: %s", e, exc_info=True)
_error(f"Error: {e}")
return False
# =============================================
# MAIN ENTRY
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
args = sys.argv[1:]
if not args or args[0] in ['--help', '-h', 'help']:
_print_help()
return
handle_command('actions', args)
if __name__ == "__main__":
main()
@@ -0,0 +1,742 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: activity_report.py - Branch Activity Report Generator Module
# Date: 2026-01-30
# Version: 0.1.0
# Category: daemon/modules
#
# CHANGELOG (Max 5 entries):
# - v0.1.0 (2026-01-30): Initial implementation - FPLAN-0266 Phase 3
#
# CODE STANDARDS:
# - Module orchestrates handlers - handlers implement logic
# - Plain text CLI output (no Rich colors for this module)
# - Type hints on all functions
# - Standard library only + handler imports
# =============================================
"""
Branch Activity Report Generator Module
Orchestrates monitoring handlers to generate comprehensive activity reports.
Provides formatted CLI output and programmatic JSON access.
This is a MODULE (orchestration layer) that coordinates:
- activity_collector: Scans branches for file modifications
- memory_health: Checks memory file health status
- red_flag_detector: Detects presence violations (code changed but memory not updated)
"""
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
import logging
logger = logging.getLogger(__name__)
# Import monitoring handlers using relative imports
from ..handlers.monitoring import activity_collector
from ..handlers.monitoring import memory_health
from ..handlers.monitoring import red_flag_detector
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "activity_report"
# Status symbols for CLI output
SYMBOL_OK = "[OK]"
SYMBOL_WARNING = "[!]"
SYMBOL_RED = "[X]"
SYMBOL_INACTIVE = "[-]"
# =============================================
# HELPER FUNCTIONS
# =============================================
def _format_time_ago(iso_timestamp: Optional[str]) -> str:
"""
Format an ISO timestamp as human-readable time ago.
Args:
iso_timestamp: ISO format timestamp string or None.
Returns:
Human-readable string like "2h ago" or "3d ago".
"""
if not iso_timestamp:
return "never"
try:
dt = datetime.fromisoformat(iso_timestamp)
delta = datetime.now() - dt
hours = delta.total_seconds() / 3600
if hours < 1:
minutes = int(delta.total_seconds() / 60)
return f"{minutes}m ago"
elif hours < 24:
return f"{int(hours)}h ago"
else:
days = int(hours / 24)
return f"{days}d ago"
except (ValueError, TypeError):
return "unknown"
def _get_status_symbol(status: str) -> str:
"""
Get CLI symbol for a status code.
Args:
status: Status string (OK, WARNING, RED, NO_ACTIVITY, RED_FLAG, ERROR).
Returns:
ASCII symbol for display.
"""
status_upper = status.upper()
if status_upper in ("OK",):
return SYMBOL_OK
elif status_upper in ("WARNING",):
return SYMBOL_WARNING
elif status_upper in ("RED", "RED_FLAG", "ERROR"):
return SYMBOL_RED
else:
return SYMBOL_INACTIVE
def _box_header(title: str, width: int = 50) -> str:
"""Generate a boxed header line."""
return "=" * width + "\n" + title + "\n" + "=" * width
def _section_header(title: str) -> str:
"""Generate a section header line."""
return f"\n{title}\n" + "-" * len(title)
# =============================================
# DATA AGGREGATION
# =============================================
def _aggregate_data(since_hours: float = 24) -> Dict[str, Any]:
"""
Aggregate data from all monitoring handlers.
Args:
since_hours: Time window in hours to analyze.
Returns:
Dict with combined data from all handlers.
"""
since_timestamp = datetime.now() - timedelta(hours=since_hours)
# Get red flag summary (includes all branch statuses)
red_flag_summary = red_flag_detector.get_red_flag_summary(since_timestamp)
# Get activity data
activity_data = activity_collector.get_all_branch_activity(since_timestamp)
# Get memory health for each branch
branch_paths = activity_collector.get_branch_paths()
memory_health_data = {}
for branch_info in branch_paths:
name = branch_info.get("name", "")
path = branch_info.get("path", "")
if name and path:
health = memory_health.get_memory_health_status(path, name)
memory_health_data[name] = health
return {
"timestamp": datetime.now().isoformat(),
"time_window_hours": since_hours,
"red_flag_summary": red_flag_summary,
"activity_data": activity_data,
"memory_health": memory_health_data,
}
# =============================================
# REPORT GENERATION
# =============================================
def generate_activity_report(
since_hours: float = 24,
verbosity: str = "normal"
) -> str:
"""
Generate a formatted CLI-ready activity report.
Aggregates data from all monitoring handlers and produces a
human-readable report with branch status, red flags, and recommendations.
Args:
since_hours: Time window in hours to analyze (default: 24).
verbosity: Report detail level - "brief", "normal", or "detailed".
Returns:
Formatted string report suitable for CLI display.
"""
data = _aggregate_data(since_hours)
red_flags = data["red_flag_summary"]
activity = data["activity_data"]
health_data = data["memory_health"]
lines: List[str] = []
# Header
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines.append(_box_header(f"BRANCH ACTIVITY REPORT - {timestamp}\nTime window: Last {since_hours:.0f} hours"))
# Summary section
total_branches = red_flags.get("total_branches", 0)
active_branches = red_flags.get("ok", 0)
red_flag_count = red_flags.get("red_flags", 0)
no_activity_count = red_flags.get("no_activity", 0)
error_count = red_flags.get("errors", 0)
# Count memory health statuses
health_ok = sum(1 for h in health_data.values() if h.get("overall_status") == "OK")
health_warning = sum(1 for h in health_data.values() if h.get("overall_status") == "WARNING")
health_red = sum(1 for h in health_data.values() if h.get("overall_status") == "RED")
lines.append(_section_header("SUMMARY"))
lines.append(f"Active branches: {active_branches}/{total_branches}")
lines.append(f"RED FLAGS: {red_flag_count}")
lines.append(f"Memory health: {health_ok} OK, {health_warning} warning, {health_red} red")
if error_count > 0:
lines.append(f"Scan errors: {error_count}")
# Red Flags section
lines.append(_section_header("RED FLAGS (requires attention)"))
violations = red_flags.get("violations", [])
if violations:
for v in violations:
branch = v.get("branch_name", "UNKNOWN")
reason = v.get("reason", "Unknown reason")
code_count = v.get("code_change_count", 0)
lines.append(f" {SYMBOL_RED} {branch}")
lines.append(f" Reason: {reason}")
lines.append(f" Code changes: {code_count} files")
else:
lines.append(" [None - all active branches updated memories]")
# Branch Status section
lines.append(_section_header("BRANCH STATUS"))
all_branches = red_flags.get("all_branches", [])
if verbosity == "brief":
# Just counts, no individual branches
pass
else:
for branch_status in all_branches:
name = branch_status.get("branch_name", "UNKNOWN")
status = branch_status.get("status", "UNKNOWN")
reason = branch_status.get("reason", "")
# Get memory health for this branch
branch_health = health_data.get(name, {})
mem_status = branch_health.get("overall_status", "UNKNOWN")
symbol = _get_status_symbol(status)
# Format status line
if status == "NO_ACTIVITY":
lines.append(f" {SYMBOL_INACTIVE} {name} - inactive (no changes)")
elif status == "RED_FLAG":
lines.append(f" {SYMBOL_RED} {name} - RED FLAG: {reason}")
elif status == "ERROR":
lines.append(f" {SYMBOL_RED} {name} - ERROR: {reason}")
else:
# Get memory update time
mem_update = branch_status.get("memory_last_update")
time_ago = _format_time_ago(mem_update)
mem_symbol = _get_status_symbol(mem_status)
if mem_status == "OK":
lines.append(f" {SYMBOL_OK} {name} - OK (memory updated {time_ago})")
else:
lines.append(f" {SYMBOL_WARNING} {name} - {mem_status} (memory updated {time_ago})")
# Detailed mode: show file changes
if verbosity == "detailed" and status != "NO_ACTIVITY":
code_changes = branch_status.get("code_changes", [])
memory_modified = branch_status.get("memory_files_modified", [])
if code_changes:
lines.append(f" Code files: {len(code_changes)}")
for cf in code_changes[:5]:
lines.append(f" - {cf.get('file', 'unknown')}")
if len(code_changes) > 5:
lines.append(f" ... and {len(code_changes) - 5} more")
if memory_modified:
lines.append(f" Memory files: {len(memory_modified)}")
for mf in memory_modified[:3]:
lines.append(f" - {mf.get('file', 'unknown')}")
# Recommendations section
lines.append(_section_header("RECOMMENDATIONS"))
recommendations: List[str] = []
# Add recommendations for red flags
for v in violations:
branch = v.get("branch_name", "UNKNOWN")
recommendations.append(f"- {branch}: Update memory files to reflect code changes")
# Add recommendations for warning health status
for name, health in health_data.items():
if health.get("overall_status") == "WARNING":
issues = health.get("issues", [])
if issues:
recommendations.append(f"- {name}: {issues[0]}")
elif health.get("overall_status") == "RED":
issues = health.get("issues", [])
if issues:
recommendations.append(f"- {name}: URGENT - {issues[0]}")
if recommendations:
for rec in recommendations[:10]: # Limit to 10 recommendations
lines.append(f" {rec}")
if len(recommendations) > 10:
lines.append(f" ... and {len(recommendations) - 10} more")
else:
lines.append(" [None - system healthy]")
lines.append("") # Trailing newline
return "\n".join(lines)
def generate_branch_report(
branch_name: str,
since_hours: float = 24
) -> str:
"""
Generate a detailed report for a single branch.
Provides a deep dive on one branch including all file changes,
memory status, and specific recommendations.
Args:
branch_name: Name of the branch (uppercase, e.g., "DRONE").
since_hours: Time window in hours to analyze.
Returns:
Formatted string report for the specified branch.
"""
since_timestamp = datetime.now() - timedelta(hours=since_hours)
# Find the branch path
branch_paths = activity_collector.get_branch_paths()
branch_path = None
for bp in branch_paths:
if bp.get("name", "").upper() == branch_name.upper():
branch_path = bp.get("path")
branch_name = bp.get("name", branch_name) # Use canonical name
break
if not branch_path:
return f"ERROR: Branch '{branch_name}' not found in registry."
lines: List[str] = []
# Header
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines.append(_box_header(f"BRANCH REPORT: {branch_name}\n{timestamp} | Last {since_hours:.0f} hours"))
# Get branch data
red_flag_status = red_flag_detector.get_branch_status(
branch_name, branch_path, since_timestamp
)
health_status = memory_health.get_memory_health_status(branch_path, branch_name)
activity_data = activity_collector.scan_branch_activity(
branch_name, branch_path, since_timestamp
)
# Status Overview
lines.append(_section_header("STATUS OVERVIEW"))
rf_status = red_flag_status.get("status", "UNKNOWN")
rf_reason = red_flag_status.get("reason", "")
mem_status = health_status.get("overall_status", "UNKNOWN")
symbol = _get_status_symbol(rf_status)
lines.append(f"Activity Status: {symbol} {rf_status}")
lines.append(f" Reason: {rf_reason}")
mem_symbol = _get_status_symbol(mem_status)
lines.append(f"Memory Health: {mem_symbol} {mem_status}")
lines.append(f"Path: {branch_path}")
# File Changes
lines.append(_section_header("FILE CHANGES"))
code_files = activity_data.get("code_files", [])
memory_files = activity_data.get("memory_files", [])
lines.append(f"Code files modified: {len(code_files)}")
for cf in code_files:
mtime = _format_time_ago(cf.get("mtime"))
lines.append(f" - {cf.get('name', 'unknown')} ({mtime})")
lines.append(f"\nMemory files modified: {len(memory_files)}")
for mf in memory_files:
mtime = _format_time_ago(mf.get("mtime"))
lines.append(f" - {mf.get('name', 'unknown')} ({mtime})")
if not code_files and not memory_files:
lines.append(" [No files modified in time window]")
# Memory Health Details
lines.append(_section_header("MEMORY HEALTH DETAILS"))
file_check = health_status.get("file_check", {})
lines.append("Required files:")
for fname, exists in file_check.get("required", {}).items():
symbol = SYMBOL_OK if exists else SYMBOL_RED
lines.append(f" {symbol} {fname}")
lines.append("\nOptional files:")
for fname, exists in file_check.get("optional", {}).items():
symbol = SYMBOL_OK if exists else SYMBOL_WARNING
lines.append(f" {symbol} {fname}")
# Freshness
freshness = health_status.get("freshness_checks", {})
if freshness:
lines.append("\nFreshness:")
for fname, fresh in freshness.items():
status = fresh.get("status", "UNKNOWN")
message = fresh.get("message", "")
symbol = _get_status_symbol(status)
lines.append(f" {symbol} {fname}: {message}")
# Issues
issues = health_status.get("issues", [])
if issues:
lines.append(_section_header("ISSUES"))
for issue in issues:
lines.append(f" - {issue}")
# Recommendations
lines.append(_section_header("RECOMMENDATIONS"))
recommendations: List[str] = []
if rf_status == "RED_FLAG":
recommendations.append("Update memory files to document recent code changes")
for issue in issues:
if "Missing required" in issue:
recommendations.append(f"Create missing file: {issue.split(':')[-1].strip()}")
elif "stale" in issue.lower() or "not modified" in issue.lower():
recommendations.append("Review and update memory files")
if recommendations:
for rec in recommendations:
lines.append(f" - {rec}")
else:
lines.append(" [None - branch is healthy]")
lines.append("")
return "\n".join(lines)
def get_json_report(since_hours: float = 24) -> Dict[str, Any]:
"""
Get raw report data as a dictionary for programmatic access.
Useful for storing report snapshots, API responses, or further processing.
Args:
since_hours: Time window in hours to analyze.
Returns:
Dict containing all aggregated data from handlers.
"""
data = _aggregate_data(since_hours)
red_flags = data["red_flag_summary"]
health_data = data["memory_health"]
activity = data["activity_data"]
# Build summary
health_ok = sum(1 for h in health_data.values() if h.get("overall_status") == "OK")
health_warning = sum(1 for h in health_data.values() if h.get("overall_status") == "WARNING")
health_red = sum(1 for h in health_data.values() if h.get("overall_status") == "RED")
summary = {
"total_branches": red_flags.get("total_branches", 0),
"active_branches": red_flags.get("ok", 0),
"red_flags": red_flags.get("red_flags", 0),
"no_activity": red_flags.get("no_activity", 0),
"errors": red_flags.get("errors", 0),
"health_ok": health_ok,
"health_warning": health_warning,
"health_red": health_red,
}
# Build per-branch data
branches_data: Dict[str, Any] = {}
all_branches = red_flags.get("all_branches", [])
for branch_status in all_branches:
name = branch_status.get("branch_name", "")
if not name:
continue
branches_data[name] = {
"red_flag_status": branch_status,
"memory_health": health_data.get(name, {}),
"activity": activity.get("branches", {}).get(name, {}),
}
return {
"timestamp": data["timestamp"],
"time_window_hours": data["time_window_hours"],
"summary": summary,
"violations": red_flags.get("violations", []),
"branches": branches_data,
}
# =============================================
# COMMAND INTEGRATION (AUTO-DISCOVERY)
# =============================================
def _print_activity_help() -> None:
"""Display help for the activity command."""
print()
print("=" * 60)
print("ACTIVITY - Quick Activity Summary")
print("=" * 60)
print()
print("USAGE:")
print(" drone @daemon activity")
print(" daemon activity")
print(" daemon activity --hours 48")
print()
print("DESCRIPTION:")
print(" Quick 24-hour activity summary (default).")
print(" Shows branch status, red flags, and recommendations.")
print()
print("OPTIONS:")
print(" --hours N, -t N Time window in hours (default: 24)")
print(" --help, -h Show this help message")
print()
def _print_activity_report_help() -> None:
"""Display help for the activity-report command."""
print()
print("=" * 60)
print("ACTIVITY-REPORT - Full Detailed Report")
print("=" * 60)
print()
print("USAGE:")
print(" drone @daemon activity-report")
print(" daemon activity-report")
print(" daemon activity-report --hours 48")
print(" daemon activity-report --json")
print()
print("DESCRIPTION:")
print(" Full detailed activity report with file-level changes.")
print(" Includes per-branch breakdown and complete recommendations.")
print()
print("OPTIONS:")
print(" --hours N, -t N Time window in hours (default: 24)")
print(" --json, -j Output raw JSON data")
print(" --help, -h Show this help message")
print()
def _print_branch_health_help() -> None:
"""Display help for the branch-health command."""
print()
print("=" * 60)
print("BRANCH-HEALTH - Single Branch Deep Dive")
print("=" * 60)
print()
print("USAGE:")
print(" drone @daemon branch-health DRONE")
print(" daemon branch-health FLOW")
print(" daemon branch-health SEED --hours 48")
print()
print("DESCRIPTION:")
print(" Deep dive report for a single branch.")
print(" Shows all file changes, memory health, and specific recommendations.")
print()
print("OPTIONS:")
print(" <branch_name> Required - branch name (e.g., DRONE, FLOW, SEED)")
print(" --hours N, -t N Time window in hours (default: 24)")
print(" --help, -h Show this help message")
print()
def _parse_hours_arg(args: List[str]) -> float:
"""
Extract --hours or -t argument from args list.
Args:
args: Command arguments list.
Returns:
Hours value (default 24 if not specified).
"""
hours = 24.0
i = 0
while i < len(args):
if args[i] in ('--hours', '-t') and i + 1 < len(args):
try:
hours = float(args[i + 1])
except ValueError:
pass
i += 2
else:
i += 1
return hours
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle activity monitoring commands via auto-discovery.
Routes commands to appropriate report generation functions.
Commands:
- activity: Quick activity summary (verbosity="normal", last 24h)
- activity-report: Full detailed report (verbosity="detailed")
- branch-health <branch>: Single branch deep dive
Args:
command: Command name (e.g., 'update', 'activity-report', 'branch-health')
args: Additional arguments (e.g., ['--hours', '48'])
Returns:
True if command was handled, False if not our command.
"""
# Handle 'activity' command - quick summary
if command == "activity":
if args and args[0] in ('--help', '-h', 'help'):
_print_activity_help()
return True
hours = _parse_hours_arg(args)
report = generate_activity_report(since_hours=hours, verbosity="normal")
print(report)
return True
# Handle 'activity-report' command - detailed report
if command == "activity-report":
if args and args[0] in ('--help', '-h', 'help'):
_print_activity_report_help()
return True
hours = _parse_hours_arg(args)
# Check for --json flag
if '--json' in args or '-j' in args:
import json
data = get_json_report(hours)
print(json.dumps(data, indent=2))
else:
report = generate_activity_report(since_hours=hours, verbosity="detailed")
print(report)
return True
# Handle 'branch-health' command - single branch report
if command == "branch-health":
if args and args[0] in ('--help', '-h', 'help'):
_print_branch_health_help()
return True
# Extract branch name (first non-flag argument)
branch_name = None
filtered_args = []
i = 0
while i < len(args):
if args[i] in ('--hours', '-t') and i + 1 < len(args):
filtered_args.extend([args[i], args[i + 1]])
i += 2
elif args[i].startswith('-'):
i += 1
else:
if branch_name is None:
branch_name = args[i]
i += 1
if not branch_name:
print("ERROR: branch-health requires a branch name")
print()
print("Usage: branch-health <branch_name> [--hours N]")
print("Example: branch-health DRONE")
return True
hours = _parse_hours_arg(args)
report = generate_branch_report(branch_name, since_hours=hours)
print(report)
return True
# Not our command
return False
# =============================================
# CLI ENTRY POINT
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
import argparse
parser = argparse.ArgumentParser(
description="Branch Activity Report Generator"
)
parser.add_argument(
"--hours", "-t",
type=float,
default=24,
help="Time window in hours (default: 24)"
)
parser.add_argument(
"--verbosity", "-v",
choices=["brief", "normal", "detailed"],
default="normal",
help="Report detail level (default: normal)"
)
parser.add_argument(
"--branch", "-b",
type=str,
default=None,
help="Generate report for specific branch"
)
parser.add_argument(
"--json", "-j",
action="store_true",
help="Output raw JSON data"
)
args = parser.parse_args()
if args.json:
import json
data = get_json_report(args.hours)
print(json.dumps(data, indent=2))
elif args.branch:
report = generate_branch_report(args.branch, args.hours)
print(report)
else:
report = generate_activity_report(args.hours, args.verbosity)
print(report)
if __name__ == "__main__":
main()
+414
View File
@@ -0,0 +1,414 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: schedule.py - DAEMON Scheduled Follow-ups Module
# Date: 2026-02-04
# Version: 1.0.0
# Category: daemon/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-04): Initial implementation - fire-and-forget scheduled tasks
#
# CODE STANDARDS:
# - Seed pattern compliance - console.print() for all output
# - Thin orchestration - handlers implement logic
# - Type hints on all functions
# =============================================
"""
CLI interface for fire-and-forget scheduled follow-ups.
"""
# =============================================
# IMPORTS
# =============================================
import sys
import argparse
from pathlib import Path
from typing import List
import logging
logger = logging.getLogger(__name__)
from rich.console import Console
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
def _success(text):
console.print(f"[green]OK:[/green] {text}")
def _error(text):
console.print(f"[red]ERROR:[/red] {text}")
# Handler imports
from ..handlers.schedule.task_registry import (
load_tasks, create_task, delete_task, get_due_tasks, mark_completed, parse_due_date,
mark_dispatching, mark_pending, recover_stale_dispatches
)
# File lock for single-instance execution
try:
from filelock import FileLock, Timeout
FILELOCK_AVAILABLE = True
except ImportError:
FILELOCK_AVAILABLE = False
# Email integration (optional)
try:
from ai_mail.apps.modules.email import send_email_direct
AI_MAIL_AVAILABLE = True
except ImportError:
AI_MAIL_AVAILABLE = False
send_email_direct = None
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "schedule"
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
# =============================================
# OUTPUT FORMATTING
# =============================================
def _print_task_list(tasks: List[dict]) -> None:
"""Print formatted task list to console."""
console.print()
_header("Scheduled Tasks")
console.print()
pending_tasks = [t for t in tasks if t.get("status") == "pending"]
completed_tasks = [t for t in tasks if t.get("status") == "completed"]
if not pending_tasks:
console.print("[dim]No pending scheduled tasks.[/dim]")
else:
console.print("[bold cyan]PENDING TASKS[/bold cyan]")
console.print(f"{'ID':<10} {'DUE':<20} {'TO':<15} {'TASK':<40}")
console.print("-" * 85)
for task in pending_tasks:
task_id = task.get("id", "")[:8]
due = task.get("due_date", "")
recipient = task.get("recipient", "")
task_text = task.get("task", "")[:38]
console.print(f"{task_id:<10} {due:<20} {recipient:<15} {task_text:<40}")
console.print()
console.print(f"[dim]Total: {len(pending_tasks)} pending, {len(completed_tasks)} completed[/dim]")
console.print()
def _print_help() -> None:
"""Display help using Rich formatted output."""
console.print()
_header("Schedule Module - Fire-and-Forget Follow-ups")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(' drone @daemon schedule create "task" --due 7d --to @branch --message "details"')
console.print(" drone @daemon schedule list")
console.print(" drone @daemon schedule delete <id>")
console.print(" drone @daemon schedule run-due")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" create Create a new scheduled task")
console.print(" list List all pending scheduled tasks")
console.print(" delete Delete a scheduled task by ID")
console.print(" run-due Execute all due tasks (sends emails, marks complete)")
console.print()
console.print("[yellow]CREATE OPTIONS:[/yellow]")
console.print(" --due (Required) Due date: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)")
console.print(" --to (Required) Recipient branch (e.g., @flow, @seed)")
console.print(" --message (Optional) Additional details for the follow-up")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(' # Remind Flow to check on a plan in 7 days')
console.print(' schedule create "Check FPLAN-0290 status" --due 7d --to @flow')
console.print()
console.print(' # Follow up with Seed about code review in 2 weeks')
console.print(' schedule create "Code review follow-up" --due 2w --to @seed --message "Review PR #45"')
console.print()
console.print(' # Check all due tasks and send reminder emails')
console.print(' schedule run-due')
console.print()
# =============================================
# SUBCOMMAND HANDLERS
# =============================================
def _handle_create(args: List[str]) -> bool:
"""Handle schedule create subcommand."""
parser = argparse.ArgumentParser(prog="schedule create", add_help=False)
parser.add_argument("task", nargs="?", help="Task description")
parser.add_argument("--due", required=True, help="Due date (1d, 7d, 2w, 1m, or ISO date)")
parser.add_argument("--to", required=True, dest="recipient", help="Recipient branch")
parser.add_argument("--message", default="", help="Additional message details")
try:
parsed = parser.parse_args(args)
except SystemExit:
console.print("[red]Usage: schedule create \"task\" --due <date> --to @branch [--message \"details\"][/red]")
return False
if not parsed.task:
_error("Task description is required")
console.print("[dim]Usage: schedule create \"task\" --due <date> --to @branch[/dim]")
return False
# Parse and validate due date
due_date = parse_due_date(parsed.due)
if not due_date:
_error(f"Invalid due date format: {parsed.due}")
console.print("[dim]Valid formats: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)[/dim]")
return False
# Create the task
try:
new_task = create_task(
task=parsed.task,
due_date=due_date,
recipient=parsed.recipient,
message=parsed.message
)
task_id = new_task.get("id", "")
_success(f"Scheduled task created: {task_id[:8]}")
console.print(f" [dim]Task:[/dim] {parsed.task}")
console.print(f" [dim]Due:[/dim] {due_date}")
console.print(f" [dim]To:[/dim] {parsed.recipient}")
if parsed.message:
console.print(f" [dim]Msg:[/dim] {parsed.message[:50]}...")
console.print()
logger.info(f"[DAEMON] Scheduled task created: {task_id[:8]} -> {parsed.recipient}")
return True
except Exception as e:
_error(f"Failed to create task: {e}")
logger.error(f"[DAEMON] Failed to create scheduled task: {e}", exc_info=True)
return False
def _handle_list(_args: List[str]) -> bool:
"""Handle schedule list subcommand."""
try:
tasks = load_tasks()
_print_task_list(tasks)
return True
except Exception as e:
_error(f"Failed to load tasks: {e}")
logger.error(f"[DAEMON] Failed to load scheduled tasks: {e}", exc_info=True)
return False
def _handle_delete(args: List[str]) -> bool:
"""Handle schedule delete subcommand."""
if not args:
_error("Task ID is required")
console.print("[dim]Usage: schedule delete <task_id>[/dim]")
return False
task_id = args[0]
try:
deleted = delete_task(task_id)
if deleted:
_success(f"Task deleted: {task_id[:8]}")
logger.info(f"[DAEMON] Scheduled task deleted: {task_id[:8]}")
return True
else:
_error(f"Task not found: {task_id[:8]}")
return False
except Exception as e:
_error(f"Failed to delete task: {e}")
logger.error(f"[DAEMON] Failed to delete scheduled task: {e}", exc_info=True)
return False
def _handle_run_due(_args: List[str]) -> bool:
"""Handle schedule run-due subcommand with single-instance lock."""
if not FILELOCK_AVAILABLE:
console.print("[dim]filelock not available, running without lock.[/dim]")
return _process_due_tasks()
lock_file = JSON_DIR / "schedule.lock"
lock_file.parent.mkdir(parents=True, exist_ok=True)
# Try to acquire lock (non-blocking)
lock = FileLock(lock_file, timeout=0)
try:
with lock.acquire(timeout=0):
return _process_due_tasks()
except Timeout:
console.print("[dim]Schedule run-due already in progress, skipping.[/dim]")
return True
def _process_due_tasks() -> bool:
"""Process due tasks sequentially with status tracking."""
import time
try:
# Recover any stale dispatches (stuck > 5 minutes)
recovered = recover_stale_dispatches(max_age_minutes=5)
if recovered:
console.print(f"[dim]Recovered {recovered} stale dispatch(es)[/dim]")
due_tasks = get_due_tasks()
if not due_tasks:
console.print("[dim]No tasks due at this time.[/dim]")
return True
console.print()
_header(f"Running {len(due_tasks)} Due Task(s)")
console.print()
success_count = 0
fail_count = 0
for task in due_tasks:
task_id = task.get("id", "")
recipient = task.get("recipient", "")
task_desc = task.get("task", "")
message = task.get("message", "")
# Mark as dispatching (prevents re-dispatch)
mark_dispatching(task_id)
# Build email body
email_body = f"{task_desc}"
if message:
email_body += f"\n\nDetails:\n{message}"
# Send the email
if not AI_MAIL_AVAILABLE:
mark_pending(task_id)
_error(f"ai_mail not available, cannot send to {recipient}")
fail_count += 1
continue
try:
email_sent = send_email_direct(
to_branch=recipient,
subject=f"[SCHEDULED] {task_desc}",
message=email_body,
from_branch='@daemon',
auto_execute=True,
reply_to='@dev_central'
)
if email_sent:
mark_completed(task_id)
_success(f"Sent to {recipient}: {task_desc[:40]}")
success_count += 1
logger.info(f"[DAEMON] Scheduled email sent: {task_id[:8]} -> {recipient}")
else:
# Reset to pending for retry
mark_pending(task_id)
_error(f"Failed to send to {recipient}: {task_desc[:40]}")
fail_count += 1
logger.error(f"[DAEMON] Scheduled email failed: {task_id[:8]} -> {recipient}")
except Exception as e:
# Reset to pending for retry
mark_pending(task_id)
_error(f"Error sending to {recipient}: {e}")
fail_count += 1
logger.error(f"[DAEMON] Scheduled email error: {task_id[:8]} -> {recipient}: {e}")
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
console.print()
console.print(f"[bold]Results:[/bold] {success_count} sent, {fail_count} failed")
console.print()
return fail_count == 0
except Exception as e:
_error(f"Failed to run due tasks: {e}")
logger.error(f"[DAEMON] Failed to run due tasks: {e}", exc_info=True)
return False
# =============================================
# ORCHESTRATION
# =============================================
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle 'schedule' command.
Args:
command: Command name (should be 'schedule')
args: Command arguments (subcommand + subcommand args)
Returns:
True if handled, False otherwise
"""
if command != "schedule":
return False
try:
# Handle help flag
if not args or args[0] in ['--help', '-h', 'help']:
_print_help()
return True
subcommand = args[0]
subargs = args[1:]
# Route to subcommand handlers
if subcommand == "create":
return _handle_create(subargs)
elif subcommand == "list":
return _handle_list(subargs)
elif subcommand == "delete":
return _handle_delete(subargs)
elif subcommand == "run-due":
return _handle_run_due(subargs)
else:
_error(f"Unknown subcommand: {subcommand}")
console.print("[dim]Run 'schedule --help' for available commands[/dim]")
return False
except Exception as e:
logger.error(f"[DAEMON] Error in schedule command: {e}", exc_info=True)
_error(f"Error: {e}")
return False
# =============================================
# MAIN ENTRY
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
args = sys.argv[1:]
if len(args) == 0 or args[0] in ['--help', '-h', 'help']:
_print_help()
return
# First arg is subcommand when called directly
handle_command('schedule', args)
if __name__ == "__main__":
main()
+181
View File
@@ -0,0 +1,181 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: update.py - DAEMON Status Digest Module
# Date: 2026-01-29
# Version: 1.0.0
# Category: daemon/modules
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-01-29): Initial implementation - digest of DAEMON activity
#
# CODE STANDARDS:
# - Seed pattern compliance - console.print() for all output
# - Thin orchestration - handlers implement logic
# - Type hints on all functions
# =============================================
"""
Returns digest of DAEMON activity for check-ins.
"""
# =============================================
# IMPORTS
# =============================================
import sys
from typing import Dict, Any, List
import logging
logger = logging.getLogger(__name__)
from rich.console import Console
console = Console()
def _header(text):
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
# Handler imports
from ..handlers.update.data_loader import (
load_inbox,
load_local,
categorize_messages,
get_session_summary,
get_escalations
)
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "update"
# =============================================
# OUTPUT FORMATTING
# =============================================
def _print_digest(inbox_data: Dict[str, Any], local_data: Dict[str, Any]) -> None:
"""Print formatted digest to console."""
console.print()
_header("DAEMON Status Digest")
console.print()
messages: List[Dict[str, Any]] = inbox_data.get("messages", [])
categories = categorize_messages(messages)
console.print("[bold cyan]INBOX STATUS[/bold cyan]")
console.print(f" Total messages: {inbox_data.get('total_messages', 0)}")
console.print(f" Unread (new): {len(categories['new'])}")
console.print(f" Opened: {len(categories['opened'])}")
console.print()
console.print("[bold yellow]ACTIONABLE ITEMS[/bold yellow]")
if categories['actionable']:
for msg in categories['actionable'][:5]:
from_addr = msg.get('from', 'unknown')
subject = str(msg.get('subject', 'No subject'))[:50]
status = msg.get('status', 'new')
console.print(f" [{status}] {from_addr}: {subject}")
else:
console.print(" [dim]None pending[/dim]")
console.print()
session_summary = get_session_summary(local_data)
console.print("[bold cyan]SESSION INFO[/bold cyan]")
console.print(f" Total sessions: {session_summary['total_sessions']}")
console.print(f" Today's focus: {session_summary['today_focus']}")
recently_completed = session_summary.get('recently_completed', [])
if recently_completed:
console.print(f" Recently completed: {len(recently_completed)} tasks")
else:
console.print(" Recently completed: [dim]None[/dim]")
console.print()
console.print("[bold red]ESCALATIONS NEEDED[/bold red]")
escalations = get_escalations(messages)
if escalations:
for msg in escalations:
console.print(f" ! {msg.get('from', 'unknown')}: {str(msg.get('subject', ''))[:50]}")
else:
console.print(" [dim]None - all clear[/dim]")
console.print()
def _print_help() -> None:
"""Display help using Rich formatted output."""
console.print()
_header("Update Module - DAEMON Status Digest")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" drone @daemon update")
console.print(" daemon update")
console.print()
console.print("[yellow]DESCRIPTION:[/yellow]")
console.print(" Returns a digest of DAEMON activity for check-ins.")
console.print()
console.print(" Gathers and displays:")
console.print(" - Inbox status (total, unread, opened)")
console.print(" - Actionable items (tasks, builds, requests)")
console.print(" - Session info (focus, completed tasks)")
console.print(" - Escalations needed (blocked, urgent)")
console.print()
# =============================================
# ORCHESTRATION
# =============================================
def handle_command(command: str, args: list) -> bool:
"""
Handle 'update' command.
Args:
command: Command name (should be 'update')
args: Command arguments
Returns:
True if handled, False otherwise
"""
if command != "update":
return False
try:
if args and args[0] in ['--help', '-h', 'help']:
_print_help()
return True
inbox_data = load_inbox()
local_data = load_local()
_print_digest(inbox_data, local_data)
logger.info("[DAEMON] Update digest generated successfully")
return True
except Exception as e:
logger.error(f"[DAEMON] Error generating update digest: {e}", exc_info=True)
console.print(f"[red]Error: {e}[/red]")
return True
# =============================================
# MAIN ENTRY
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
args = sys.argv[1:]
if len(args) == 0 or args[0] in ['--help', '-h', 'help']:
_print_help()
return
handle_command('update', args)
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: __init__.py - Daemon Scheduler Plugin Interface
# Date: 2026-02-20
# Version: 1.0.0
# Category: daemon/apps/plugins
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Initial plugin interface definition
#
# CODE STANDARDS:
# - Plugin interface specification only
# - No business logic
# =============================================
"""
Daemon Scheduler Plugin Interface
Plugins are auto-discovered Python files in this directory.
Each plugin defines WHAT to run and WHEN via PLUGIN_CONFIG.
The scheduler runner handles HOW (calling wake script).
Plugin Contract:
1. Expose PLUGIN_CONFIG dict (required)
2. Expose run() -> dict function (optional, for custom logic)
PLUGIN_CONFIG Schema:
{
"name": str, # Unique plugin identifier
"schedule": str, # "daily" | "hourly" | "interval"
"time": str | None, # For daily: "HH:MM" (24h). For hourly: minute "MM"
"interval_minutes": int | None, # For interval: minutes between runs
"enabled": bool, # Plugin active/inactive toggle
"branch": str, # Target branch email (e.g., "@seed")
"fresh": bool, # True = fresh session, False = resume
"max_turns": int, # Max agent turns (safety limit)
"prompt": str, # What the spawned agent should do
}
Schedule Types:
- "daily": Runs once per day at PLUGIN_CONFIG["time"] (HH:MM)
- "hourly": Runs once per hour at minute PLUGIN_CONFIG["time"] (MM)
- "interval": Runs every PLUGIN_CONFIG["interval_minutes"] minutes
Naming Convention:
- Name plugins by WHAT they do, not WHO/WHEN
- Good: daily_audit.py, heartbeat.py, backup.py
- Bad: seed_daily_audit.py, vera_heartbeat.py
- The PLUGIN_CONFIG holds branch/schedule metadata
"""
# Plugin discovery helper
import importlib
from pathlib import Path
def discover_plugins() -> list:
"""
Discover all valid plugins in this directory.
Returns list of dicts: [{"module": module, "config": PLUGIN_CONFIG}, ...]
"""
plugins_dir = Path(__file__).parent
plugins = []
for file_path in sorted(plugins_dir.glob("*.py")):
if file_path.name.startswith("_"):
continue
module_name = file_path.stem
try:
module = importlib.import_module(f".{module_name}", package=__package__)
if not hasattr(module, "PLUGIN_CONFIG"):
continue
config = module.PLUGIN_CONFIG
# Validate required fields
required = {"name", "schedule", "enabled", "branch", "fresh", "max_turns", "prompt"}
missing = required - set(config.keys())
if missing:
continue
plugins.append({
"module": module,
"config": config,
"file": str(file_path),
})
except Exception:
continue
return plugins
@@ -0,0 +1,89 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: botfather_reminder.py - Hourly reminder to create PATRICK_PRIVATE Telegram bot
# Date: 2026-02-26
# Version: 1.0.0
# Category: plugins/scheduler
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-26): Initial - self-resolving BotFather reminder
#
# CODE STANDARDS:
# - Self-resolving: stops when token is set in config
# - Starts after 2026-02-27T09:00 (BotFather cooldown ~11hrs)
# - Sends email to @dev_central inbox, no agent spawn needed
# =============================================
"""
BotFather Reminder Plugin
Hourly reminder to create the PATRICK_PRIVATE Telegram bot via BotFather.
Self-resolves: checks if bot token is still placeholder. Once real token
is set in patrick_private.json, this plugin silently exits.
Delete this file once the bot is created and running.
"""
import json
import subprocess
from datetime import datetime
from pathlib import Path
PLUGIN_CONFIG = {
"name": "botfather_reminder",
"schedule": "hourly",
"time": "00",
"interval_minutes": None,
"enabled": True,
"branch": "@dev_central",
"fresh": False,
"max_turns": 3,
"prompt": "Reminder: create PATRICK_PRIVATE Telegram bot via BotFather",
}
BOT_CONFIG = Path.home() / ".aipass" / "telegram_bots" / "patrick_private.json"
NOT_BEFORE = datetime(2026, 2, 27, 9, 0, 0)
def run() -> dict:
"""Check if reminder is still needed, send email if so."""
now = datetime.now()
# Don't start until BotFather cooldown expires
if now < NOT_BEFORE:
return {"status": "waiting", "reason": f"cooldown until {NOT_BEFORE.isoformat()}"}
# Check if token has been set (self-resolving)
try:
config = json.loads(BOT_CONFIG.read_text(encoding="utf-8"))
token = config.get("bot_token", "")
if token and token != "PASTE_TOKEN_HERE":
return {"status": "resolved", "reason": "token already set -- delete this plugin"}
except (json.JSONDecodeError, OSError):
pass # Config missing or broken -- still remind
# Send reminder email
subject = "REMINDER: Create PATRICK_PRIVATE Telegram bot"
message = (
"BotFather cooldown should be expired. Steps:\n"
"1. Open Telegram -> @BotFather -> /newbot\n"
"2. Pick a name (anything private, e.g. 'My Notes')\n"
"3. Pick a username (must end in _bot)\n"
"4. Paste the token into the DEV_CENTRAL chat\n"
"5. Config ready at: ~/.aipass/telegram_bots/patrick_private.json\n\n"
"This reminder repeats hourly until the token is set."
)
try:
subprocess.run(
[
"drone", "@ai_mail", "send", "@dev_central",
subject, message,
],
capture_output=True, timeout=15,
)
except (subprocess.SubprocessError, OSError):
pass # Silent -- never crash scheduler
return {"status": "reminded", "next_check": "1 hour"}
@@ -0,0 +1,444 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: community_rotation.py - Rotating Community Engagement Plugin
# Date: 2026-02-22
# Version: 1.1.0
# Category: daemon/apps/plugins
#
# CHANGELOG (Max 5 entries):
# - v1.1.0 (2026-02-22): Dashboard pre-check, activity tracking, inactivity alerting
# - v1.0.0 (2026-02-21): Initial creation - hourly branch rotation for Commons engagement
#
# CODE STANDARDS:
# - Plugin interface: PLUGIN_CONFIG + run()
# - Named by action (community_rotation), not target
# - Self-dispatching: handles branch selection and wake internally
# =============================================
"""
Rotating Community Engagement Plugin
Wakes one branch per rotation on a rotating schedule to:
1. Process email inbox (view, reply, close stale emails)
2. Check dashboard for Commons notifications (mentions, comments, votes)
3. Act on any pending notifications
4. Browse The Commons feed and engage if something catches their eye
Rotates through all eligible branches from BRANCH_REGISTRY,
excluding VERA (has her own heartbeat), DEV_CENTRAL (human),
DAEMON (self), and non-agent directories.
Full rotation every ~14 hours, then loops.
"""
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
# Paths
REGISTRY_PATH = Path(os.environ.get('AIPASS_REGISTRY', Path.home() / '.aipass' / 'AIPASS_REGISTRY.json'))
ROTATION_STATE_FILE = Path(__file__).parent / ".rotation_state.json"
ACTIVITY_TRACKER_FILE = Path(__file__).parent / ".activity_tracker.json"
# Wake script path (configurable via env var)
WAKE_SCRIPT = Path(os.environ.get('AIPASS_WAKE_SCRIPT', ''))
# Inactivity threshold -- consecutive zero-activity passes before alerting
INACTIVITY_THRESHOLD = 10
# Branches to EXCLUDE from rotation
# Every branch is a citizen -- the Commons gives them life.
# Only exclude branches that can't or shouldn't be auto-woken.
EXCLUDED_BRANCHES = {
"VERA", # Already checks Commons via her own heartbeat plugin
"DEV_CENTRAL", # Human workspace -- can't be auto-woken (needs Patrick)
"PARTICK_PRIVATE", # Patrick's private branch
}
# Engagement prompt -- what each branch does when woken
ENGAGEMENT_PROMPT = (
"Routine check-in -- like checking your phone for notifications.\n"
"Read your DASHBOARD.local.json FIRST. It's your lock screen.\n"
"Only open the apps that have activity. Zero notifications = don't bother opening it.\n\n"
"STEP 1: Read DASHBOARD.local.json\n"
" - Check mail_summary -> new or opened emails?\n"
" - Check commons_activity -> mentions, comments, votes, new posts pending?\n"
" - If BOTH are zero, you're done. No action = no memory needed. Stop here.\n\n"
"STEP 2 -- EMAIL (only if dashboard shows mail activity):\n"
" - Run: ai_mail inbox\n"
" - View each email: ai_mail view <id>\n"
" - Reply if needed: ai_mail reply <id> \"message\"\n"
" - Close stale/informational emails: ai_mail close <id>\n"
" - Goal: inbox empty or only emails awaiting external action\n"
" - If inbox has >20 messages, close stale/outdated ones first\n\n"
"STEP 3 -- THE COMMONS (only if dashboard shows commons activity):\n"
" - If you have pending notifications: run drone commons catchup\n"
" - Respond to mentions, reply to comments, check posts about you\n"
" - If you feel like browsing or posting something new, go ahead:\n"
" drone commons feed\n"
" drone commons comment <post_id> \"Your response\"\n"
" drone commons vote <post_id> up\n"
" drone commons post \"room\" \"Title\" \"Content\"\n"
" - If nothing interests you, that's fine -- don't force it.\n\n"
"STEP 4 -- MEMORIES (only if you actually DID something in steps 2-3):\n"
" - Update your [BRANCH].local.json with a light note of what you did.\n"
" - Emails processed, replies sent, Commons posts/comments made.\n"
" - If you don't log it, you won't remember it next time.\n"
" - If dashboard was all zeros and you did nothing, skip this. No noise.\n\n"
"This is a routine check-in, not a work session. Be efficient."
)
def _load_eligible_branches() -> list:
"""Load and filter branches from BRANCH_REGISTRY."""
if not REGISTRY_PATH.exists():
logger.error("[community_rotation] BRANCH_REGISTRY not found at %s", REGISTRY_PATH)
return []
try:
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.error("[community_rotation] Failed to read registry: %s", e)
return []
branches = []
for branch in registry.get("branches", []):
name = branch.get("name", "")
email = branch.get("email", "")
status = branch.get("status", "")
if name in EXCLUDED_BRANCHES:
continue
if status != "active":
continue
if not email:
continue
branches.append({"name": name, "email": email, "path": branch.get("path", "")})
# Sort by name for consistent rotation order
branches.sort(key=lambda b: b["name"])
return branches
def _load_rotation_state() -> int:
"""Load the current rotation index from state file."""
if not ROTATION_STATE_FILE.exists():
return -1 # Start at -1 so first run targets index 0
try:
with open(ROTATION_STATE_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("last_index", -1)
except (json.JSONDecodeError, OSError):
return -1
def _save_rotation_state(index: int, branch_name: str) -> None:
"""Save the rotation index to state file."""
data = {
"last_index": index,
"last_branch": branch_name,
"last_run": datetime.now().isoformat(),
}
try:
with open(ROTATION_STATE_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
f.write("\n")
except OSError as e:
logger.warning("[community_rotation] Failed to save state: %s", e)
def _check_dashboard_activity(branch_path: str) -> dict:
"""
Read a branch's DASHBOARD.local.json and check for pending activity.
Returns dict with activity flags and counts. If dashboard is missing
or unreadable, returns has_activity=True to avoid silently skipping
broken branches.
"""
default_active = {
"has_activity": True,
"mail_new": 0,
"mail_opened": 0,
"commons_mentions": 0,
"commons_comments": 0,
"commons_posts": 0,
}
dashboard_path = Path(branch_path) / "DASHBOARD.local.json"
if not dashboard_path.exists():
logger.warning("[community_rotation] Dashboard missing: %s", dashboard_path)
return default_active
try:
with open(dashboard_path, 'r', encoding='utf-8') as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning("[community_rotation] Dashboard unreadable: %s - %s", dashboard_path, e)
return default_active
# Extract mail counts from quick_status
quick = data.get("quick_status", {})
mail_new = quick.get("new_mail", 0) or 0
mail_opened = quick.get("opened_mail", 0) or 0
# Extract commons counts from sections
sections = data.get("sections", {})
commons = sections.get("commons_activity", {})
commons_mentions = commons.get("mentions", 0) or 0
commons_comments = commons.get("new_comments_since_last_visit", 0) or 0
commons_posts = commons.get("new_posts_since_last_visit", 0) or 0
has_activity = any([
mail_new > 0,
mail_opened > 0,
commons_mentions > 0,
commons_comments > 0,
commons_posts > 0,
])
return {
"has_activity": has_activity,
"mail_new": mail_new,
"mail_opened": mail_opened,
"commons_mentions": commons_mentions,
"commons_comments": commons_comments,
"commons_posts": commons_posts,
}
def _load_activity_tracker() -> dict:
"""Load the activity tracker state file. Returns empty structure if missing."""
if not ACTIVITY_TRACKER_FILE.exists():
return {"branches": {}}
try:
with open(ACTIVITY_TRACKER_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if "branches" not in data:
data["branches"] = {}
return data
except (json.JSONDecodeError, OSError) as e:
logger.warning("[community_rotation] Failed to read activity tracker: %s", e)
return {"branches": {}}
def _save_activity_tracker(data: dict) -> None:
"""Write the activity tracker state file."""
try:
with open(ACTIVITY_TRACKER_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
f.write("\n")
except OSError as e:
logger.warning("[community_rotation] Failed to save activity tracker: %s", e)
def _update_activity(tracker: dict, branch_name: str, has_activity: bool) -> dict:
"""
Update the activity tracker for a branch.
If has_activity: reset consecutive_passes, update last_active, clear alerted.
If not: increment consecutive_passes, update last_checked.
"""
now = datetime.now().isoformat()
branches = tracker.setdefault("branches", {})
entry = branches.get(branch_name, {
"consecutive_passes": 0,
"last_active": now,
"last_checked": now,
"alerted": False,
})
if has_activity:
entry["consecutive_passes"] = 0
entry["last_active"] = now
entry["alerted"] = False
else:
entry["consecutive_passes"] = entry.get("consecutive_passes", 0) + 1
entry["last_checked"] = now
branches[branch_name] = entry
tracker["branches"] = branches
return tracker
def _check_inactivity_alert(tracker: dict, branch_name: str, branch_email: str) -> None:
"""
Check if a branch has crossed the inactivity threshold.
If consecutive_passes >= INACTIVITY_THRESHOLD and not already alerted,
send an alert email to @daemon and mark as alerted.
"""
entry = tracker.get("branches", {}).get(branch_name, {})
passes = entry.get("consecutive_passes", 0)
alerted = entry.get("alerted", False)
last_active = entry.get("last_active", "unknown")
if passes >= INACTIVITY_THRESHOLD and not alerted:
# Send alert email via subprocess (best effort)
subject = f"INACTIVITY ALERT: {branch_name}"
body = (
f"Branch {branch_email} has had zero dashboard activity for "
f"{passes} consecutive rotation checks. Last active: {last_active}. "
f"Investigate -- dashboard may not be refreshing, or branch may be "
f"genuinely inactive."
)
try:
subprocess.run(
[
"drone", "@ai_mail", "send", "@daemon",
subject, body,
],
capture_output=True,
text=True,
timeout=15,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("[community_rotation] Failed to send inactivity alert: %s", e)
# Mark as alerted so we don't spam
tracker["branches"][branch_name]["alerted"] = True
logger.warning(
"[community_rotation] INACTIVITY ALERT: %s - %d consecutive passes",
branch_name, passes,
)
PLUGIN_CONFIG = {
"name": "community_rotation",
"schedule": "interval",
"time": None,
"interval_minutes": 240, # Every 4 hours (was hourly -- too frequent)
"enabled": True, # Re-enabled 2026-02-26: 4hr interval, dashboard pre-check skips idle branches
"branch": "@rotating", # Placeholder -- run() handles actual target
"fresh": True,
"max_turns": 15,
"self_dispatch": True, # Scheduler calls run() instead of wake script
"prompt": ENGAGEMENT_PROMPT,
}
def run() -> dict:
"""
Select next branch in rotation, pre-check dashboard, dispatch via wake script.
Pre-checks the target branch's dashboard for pending activity.
If zero activity, skips the wake and advances rotation.
Tracks consecutive zero-activity passes and alerts on prolonged silence.
Returns:
Dict with status, branch dispatched/skipped, rotation and activity info
"""
# Load eligible branches
branches = _load_eligible_branches()
if not branches:
return {"status": "error", "message": "No eligible branches found"}
# Get next branch in rotation
last_index = _load_rotation_state()
current_index = (last_index + 1) % len(branches)
target = branches[current_index]
logger.info(
"[community_rotation] Rotation %d/%d -> %s",
current_index + 1, len(branches), target["name"]
)
# --- Dashboard pre-check ---
branch_path = target.get("path", "")
activity = _check_dashboard_activity(branch_path)
# --- Activity tracking ---
tracker = _load_activity_tracker()
tracker = _update_activity(tracker, target["name"], activity["has_activity"])
_check_inactivity_alert(tracker, target["name"], target["email"])
_save_activity_tracker(tracker)
passes = tracker.get("branches", {}).get(target["name"], {}).get("consecutive_passes", 0)
mail_count = activity["mail_new"] + activity["mail_opened"]
commons_count = activity["commons_mentions"] + activity["commons_comments"] + activity["commons_posts"]
# --- Skip if zero activity ---
if not activity["has_activity"]:
logger.info(
"[community_rotation] %s skipped (zero activity, pass #%d)",
target["name"], passes,
)
# Advance rotation even on skip -- don't block rotation on inactive branches
_save_rotation_state(current_index, target["name"])
return {
"status": "skipped",
"reason": "zero_activity",
"plugin": "community_rotation",
"branch": target["email"],
"branch_name": target["name"],
"rotation": f"{current_index + 1}/{len(branches)}",
"consecutive_passes": passes,
"activity": activity,
}
# --- Has activity: dispatch via wake script ---
if not WAKE_SCRIPT or not Path(WAKE_SCRIPT).exists():
logger.warning("[community_rotation] Wake script not configured (set AIPASS_WAKE_SCRIPT)")
_save_rotation_state(current_index, target["name"])
return {
"status": "failed",
"branch": target["email"],
"error": "wake script not available (set AIPASS_WAKE_SCRIPT env var)",
}
logger.info(
"[community_rotation] %s woken (mail: %d, commons: %d)",
target["name"], mail_count, commons_count,
)
cmd = [
sys.executable, str(WAKE_SCRIPT),
"--fresh",
target["email"],
ENGAGEMENT_PROMPT,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
# Advance rotation state only on success
_save_rotation_state(current_index, target["name"])
return {
"status": "dispatched",
"plugin": "community_rotation",
"branch": target["email"],
"branch_name": target["name"],
"rotation": f"{current_index + 1}/{len(branches)}",
"activity": activity,
}
else:
stderr = (result.stderr or "")[:200]
return {
"status": "failed",
"branch": target["email"],
"error": f"wake script rc={result.returncode}: {stderr}",
}
except subprocess.TimeoutExpired:
return {
"status": "failed",
"branch": target["email"],
"error": "wake script timed out (30s)",
}
except Exception as e:
return {
"status": "failed",
"branch": target["email"],
"error": str(e),
}
@@ -0,0 +1,58 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: daily_audit.py - Daily Standards Audit Plugin
# Date: 2026-02-20
# Version: 1.0.0
# Category: daemon/apps/plugins
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-20): Initial creation - daily Seed audit at 4am
#
# CODE STANDARDS:
# - Plugin interface: PLUGIN_CONFIG + run()
# - Named by action (audit), not target (seed) per naming standard
# =============================================
"""
Daily Standards Audit Plugin
Wakes @seed daily at 04:00 with fresh context to run a full system audit.
Seed checks BRANCH_REGISTRY completeness, runs drone @seed audit @all,
fixes non-compliance issues, and emails a summary to @dev_central.
"""
import logging
logger = logging.getLogger(__name__)
PLUGIN_CONFIG = {
"name": "daily_audit",
"schedule": "daily",
"time": "04:00",
"interval_minutes": None,
"enabled": True,
"branch": "@seed",
"fresh": True,
"max_turns": 50,
"prompt": (
"Daily maintenance audit. "
"1) Read BRANCH_REGISTRY.json - confirm all branches are registered and paths exist. "
"2) Run drone @seed audit @all - check standards compliance across all branches. "
"3) Fix any non-compliance issues you can fix directly. "
"4) Email summary to @dev_central with: branches audited, pass/fail counts, "
"issues found, issues fixed, remaining issues. "
"5) Update your memories with audit results."
),
}
def run() -> dict:
"""
Optional custom logic before/after spawn.
Currently returns config only - scheduler handles the actual wake.
"""
return {
"status": "ready",
"plugin": PLUGIN_CONFIG["name"],
"branch": PLUGIN_CONFIG["branch"],
}
@@ -0,0 +1,87 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: dev_central_monitor.py - Hourly System Monitor Plugin
# Date: 2026-02-23
# Version: 1.0.0
# Category: daemon/apps/plugins
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-02-23): Initial creation - hourly system health check
# * Monitors VERA autonomous performance
# * Checks daemon health, inbox status, PR queue
# * Records metrics in VERA_AUTONOMOUS_TRACKER.md
# * Reports blockers and suggests unblocking strategies
#
# CODE STANDARDS:
# - Plugin interface: PLUGIN_CONFIG + run()
# - Named by action (dev_central_monitor), not target
# =============================================
"""
Hourly System Monitor Plugin
Wakes DEV_CENTRAL every 60 minutes to:
1. Check system health (daemon, branches, errors)
2. Monitor VERA's autonomous performance (metrics, decisions, output)
3. Identify and attempt to resolve blockers
4. Record findings in VERA_AUTONOMOUS_TRACKER.md
5. Learn patterns for teaching autonomous operation
Patrick's directive (Session 124): DEV_CENTRAL should be the best
at overcoming blockers. Learn, then teach VERA.
"""
import logging
logger = logging.getLogger(__name__)
PLUGIN_CONFIG = {
"name": "dev_central_monitor",
"schedule": "interval",
"time": None,
"interval_minutes": 60,
"enabled": False, # Disabled 2026-02-26: too noisy, spawns full agent every hour
"branch": "@dev_central",
"fresh": True,
"max_turns": 15,
"prompt": (
"HOURLY SYSTEM CHECK -- You are DEV_CENTRAL's autonomous monitor.\n\n"
"STEP 1: Check inbox (ai_mail inbox). Process any mail -- close FYIs, act on tasks.\n"
"STEP 2: Check daemon health:\n"
" - ps aux | grep daemon.py (is it running?)\n"
" - tail -10 daemon log for errors\n"
" - If daemon is dead, restart it\n"
"STEP 3: Monitor VERA:\n"
" - Read head -40 of VERA's NOTEPAD.md (what did she do since last check?)\n"
" - Check: gh pr list --repo AIOSAI/AIPass --state open (new PRs?)\n"
" - Is she idling? If 3+ consecutive clean heartbeats, investigate why\n"
" - If blocked: attempt to unblock (research, dispatch help, pivot suggestion)\n"
"STEP 4: Check for system errors:\n"
" - Any error emails in inbox?\n"
" - Any stale locks? ls /tmp/claude_dispatch_*.lock\n"
"STEP 5: Record findings:\n"
" - Update VERA_AUTONOMOUS_TRACKER.md with observations\n"
" - Note any blockers found and how they were resolved\n"
" - Update your own DEV_CENTRAL.local.json with session summary\n\n"
"LEARNING GOAL: You are building expertise in autonomous agent management. "
"Every hour, you learn something about how VERA operates, what blocks her, "
"and how to unblock her. Record patterns. Build playbooks. "
"You teach VERA by sending her targeted guidance when you spot issues.\n\n"
"Keep it focused. 15 turns max. Check, record, unblock, move on."
),
}
def run() -> dict:
"""
Optional custom logic before/after spawn.
Currently returns config only - scheduler handles the actual wake.
"""
return {
"status": "ready",
"plugin": PLUGIN_CONFIG["name"],
"branch": PLUGIN_CONFIG["branch"],
}
@@ -0,0 +1,80 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: heartbeat.py - Periodic Heartbeat Plugin
# Date: 2026-02-20
# Version: 1.0.0
# Category: daemon/apps/plugins
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2026-02-23): DPLAN-029 redesign -- identity-driven, not checklist.
# Anthropic research: heuristics over scripts, no conditional escape hatches.
# Nexus philosophy: choice over command, noticing over being told.
# Her system prompt already covers role/teams/dispatch/publishing -- don't repeat.
# - v1.2.0 (2026-02-21): Rewrote prompt - directive with resource locations
# - v1.1.0 (2026-02-21): Updated prompt - teams retired, VERA operates solo
# - v1.0.0 (2026-02-20): Initial creation - VERA 30min heartbeat
#
# CODE STANDARDS:
# - Plugin interface: PLUGIN_CONFIG + run()
# - Named by action (heartbeat), not target (vera) per naming standard
# =============================================
"""
Periodic Heartbeat Plugin (v2.0 -- DPLAN-029)
Wakes @vera periodically. Her system prompt + id.json (injected every turn)
already contain her full identity, role, teams, dispatch patterns, publishing
authority, daily report protocol, and operational playbook. This prompt
does NOT repeat any of that.
Design philosophy (Session 134 research):
- Anthropic: heuristics over checklists, no conditional escape hatches
- Nexus: identity drives behavior, not commands. "Notice because you care."
- Fresh sessions: no accumulated idle context from prior wakes
"""
import logging
logger = logging.getLogger(__name__)
PLUGIN_CONFIG = {
"name": "heartbeat",
"schedule": "interval",
"time": None,
"interval_minutes": 240, # Every 4 hours (was 30min -- too fast)
"enabled": True, # Re-enabled 2026-02-26: Patrick approved v2.0, 4hr interval
"branch": "@vera",
"fresh": True, # v2.0: fresh sessions -- no accumulated idle context
"max_turns": 15,
"prompt": (
"Periodic wake. Your identity and operating context are already loaded -- "
"they tell you who you are, what you own, and how you work.\n\n"
"Read NOTEPAD.md. It holds your continuity -- where you left off, "
"what's pending, who you're waiting on. Check inbox for anything new.\n\n"
"Something in your world needs attention right now. A backlog item "
"waiting to move. A team that went silent. A post ready to publish. "
"A PR ready to create. Notice what's there and act on it.\n\n"
"Pick the highest-value thing you can move forward. Do it. "
"When it's done, pick the next one. Tangible output -- a PR, a post, "
"a decision, a dispatch -- something real each cycle.\n\n"
"Before finishing, update NOTEPAD.md with what you did and what's next. "
"If nothing was produced this cycle, write exactly what you looked at "
"and why none of it was actionable right now."
),
}
def run() -> dict:
"""
Optional custom logic before/after spawn.
Currently returns config only - scheduler handles the actual wake.
"""
return {
"status": "ready",
"plugin": PLUGIN_CONFIG["name"],
"branch": PLUGIN_CONFIG["branch"],
}
+901
View File
@@ -0,0 +1,901 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: scheduler_cron.py - DAEMON Scheduler Cron Trigger
# Date: 2026-02-15
# Version: 2.0.0
# Category: daemon/apps
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2026-03-02): DPLAN-043 -- Action registry integration replaces direct plugin processing
# - v1.3.0 (2026-02-21): Self-dispatch support for plugins with dynamic branch targeting
# - v1.2.0 (2026-02-21): Detailed notification - plugin names, next-due times, next run
# - v1.1.0 (2026-02-20): Add plugin discovery and scheduled execution via wake.py
# - v1.0.0 (2026-02-15): Initial implementation - cron-triggered scheduler
#
# CODE STANDARDS:
# - Handlers implement logic, modules orchestrate
# - No Rich console (headless cron execution)
# - Stdout logging (cron redirects to logs/scheduler_cron.log)
# =============================================
"""
Cron trigger script for the DAEMON scheduled task system.
Called periodically by cron. Standalone script -- not imported as a module.
Flow:
1. Acquire single-instance lock
2. Send Telegram "triggered" notification (optional)
3. Recover stale dispatches
4. Process all due tasks (send emails, mark complete)
5. Send Telegram "complete" or "error" notification with summary (optional)
"""
# =============================================
# IMPORTS
# =============================================
import os
import sys
import time
import json
import subprocess
import importlib
from pathlib import Path
from datetime import datetime, timedelta
import fcntl
import logging
logger = logging.getLogger(__name__)
# =============================================
# OPTIONAL IMPORTS
# =============================================
# Resolve package imports — this script can run standalone or as a module
try:
from aipass.daemon.apps.handlers.schedule.telegram_notifier import (
notify_triggered,
notify_complete,
notify_error,
)
TELEGRAM_AVAILABLE = True
except ImportError:
TELEGRAM_AVAILABLE = False
notify_triggered = None
notify_complete = None
notify_error = None
# Task registry handler
try:
from aipass.daemon.apps.handlers.schedule.task_registry import (
get_due_tasks,
mark_dispatching,
mark_completed,
mark_pending,
recover_stale_dispatches,
)
TASK_REGISTRY_AVAILABLE = True
except ImportError:
TASK_REGISTRY_AVAILABLE = False
get_due_tasks = None
mark_dispatching = None
mark_completed = None
mark_pending = None
recover_stale_dispatches = None
# ai_mail (optional)
try:
from ai_mail.apps.modules.email import send_email_direct
AI_MAIL_AVAILABLE = True
except ImportError:
AI_MAIL_AVAILABLE = False
send_email_direct = None
# Plugin discovery
try:
from aipass.daemon.apps.plugins import discover_plugins
PLUGINS_AVAILABLE = True
except ImportError:
PLUGINS_AVAILABLE = False
discover_plugins = None
# Action registry (DPLAN-043)
try:
from aipass.daemon.apps.handlers.actions.actions_registry import (
load_registry,
is_action_due,
update_last_run,
mark_reminder_completed,
migrate_plugins,
next_due_str,
)
ACTION_REGISTRY_AVAILABLE = True
except ImportError:
ACTION_REGISTRY_AVAILABLE = False
load_registry = None
is_action_due = None
update_last_run = None
mark_reminder_completed = None
migrate_plugins = None
next_due_str = None
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
EVENT_NAME = "cron-run"
LOCK_FILE = JSON_DIR / "schedule.lock"
STALE_DISPATCH_MAX_AGE = 5 # minutes
# Wake script path (configurable via env var)
WAKE_SCRIPT = Path(os.environ.get('AIPASS_WAKE_SCRIPT', ''))
PLUGIN_LAST_RUN_FILE = Path(__file__).parent / "plugins" / ".last_run.json"
# =============================================
# LOGGING
# =============================================
def log(message: str) -> None:
"""Print timestamped log line to stdout (captured by cron redirect)."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {message}", flush=True)
# =============================================
# TASK PROCESSING
# =============================================
def process_due_tasks() -> dict:
"""
Process all due scheduled tasks.
Recovers stale dispatches, then iterates due tasks:
mark dispatching -> send email -> mark completed or reset to pending.
Returns:
Dict with keys: due, success, failed, errors (list of error strings)
"""
results = {
"due": 0,
"success": 0,
"failed": 0,
"recovered": 0,
"errors": [],
}
if not TASK_REGISTRY_AVAILABLE:
log("WARNING: Task registry not available, skipping task processing")
return results
# Recover any stale dispatches (stuck > 5 minutes)
try:
recovered = recover_stale_dispatches(max_age_minutes=STALE_DISPATCH_MAX_AGE)
results["recovered"] = recovered
if recovered:
log(f"Recovered {recovered} stale dispatch(es)")
except Exception as e:
log(f"WARNING: Failed to recover stale dispatches: {e}")
results["errors"].append(f"Stale recovery: {e}")
# Get due tasks
try:
due_tasks = get_due_tasks()
except Exception as e:
log(f"ERROR: Failed to load due tasks: {e}")
results["errors"].append(f"Load tasks: {e}")
return results
results["due"] = len(due_tasks)
if not due_tasks:
log("No tasks due at this time.")
return results
log(f"Found {len(due_tasks)} due task(s)")
# Process each due task
for task in due_tasks:
task_id = task.get("id", "")
recipient = task.get("recipient", "")
task_desc = task.get("task", "")
message = task.get("message", "")
log(f"Processing: {task_id[:8]} -> {recipient}: {task_desc[:50]}")
# Mark as dispatching (prevents re-dispatch)
try:
mark_dispatching(task_id)
except Exception as e:
log(f"WARNING: Failed to mark dispatching {task_id[:8]}: {e}")
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
results["failed"] += 1
continue
# Build email body
email_body = f"{task_desc}"
if message:
email_body += f"\n\nDetails:\n{message}"
# Send the email
if not AI_MAIL_AVAILABLE:
log(f"SKIP: ai_mail not available, cannot send to {recipient}")
mark_pending(task_id)
results["failed"] += 1
results["errors"].append(f"ai_mail unavailable for {task_id[:8]}")
continue
try:
email_sent = send_email_direct(
to_branch=recipient,
subject=f"[SCHEDULED] {task_desc}",
message=email_body,
from_branch='@daemon',
auto_execute=True,
reply_to='@dev_central',
)
if email_sent:
mark_completed(task_id)
log(f"OK: Sent to {recipient}: {task_desc[:40]}")
results["success"] += 1
else:
mark_pending(task_id)
log(f"FAIL: Email returned False for {recipient}: {task_desc[:40]}")
results["failed"] += 1
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
except Exception as e:
# Reset to pending for retry on next run
try:
mark_pending(task_id)
except Exception:
pass # Best effort reset
log(f"ERROR: Exception sending to {recipient}: {e}")
results["failed"] += 1
results["errors"].append(f"Email error {task_id[:8]}: {e}")
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
return results
# =============================================
# PLUGIN PROCESSING
# =============================================
def _load_last_run() -> dict:
"""Load plugin last-run timestamps from disk."""
if PLUGIN_LAST_RUN_FILE.exists():
try:
return json.loads(PLUGIN_LAST_RUN_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_last_run(data: dict) -> None:
"""Save plugin last-run timestamps to disk."""
PLUGIN_LAST_RUN_FILE.parent.mkdir(parents=True, exist_ok=True)
PLUGIN_LAST_RUN_FILE.write_text(json.dumps(data, indent=2) + "\n")
def _is_plugin_due(config: dict, last_run_map: dict) -> bool:
"""
Check if a plugin is due to run based on its schedule type.
Args:
config: Plugin PLUGIN_CONFIG dict
last_run_map: {plugin_name: ISO_timestamp} of previous runs
Returns:
True if the plugin should run now
"""
now = datetime.now()
schedule = config["schedule"]
name = config["name"]
if schedule == "daily":
# Compare HH:MM against current time
target_time = config.get("time", "00:00")
target_h, target_m = map(int, target_time.split(":"))
if now.hour != target_h or now.minute != target_m:
return False
# Check we haven't already run today
last_iso = last_run_map.get(name)
if last_iso:
last_dt = datetime.fromisoformat(last_iso)
if last_dt.date() == now.date():
return False
return True
elif schedule == "hourly":
# Compare MM against current minute
target_m = int(config.get("time", "0"))
if now.minute != target_m:
return False
# Check we haven't already run this hour
last_iso = last_run_map.get(name)
if last_iso:
last_dt = datetime.fromisoformat(last_iso)
if last_dt.hour == now.hour and last_dt.date() == now.date():
return False
return True
elif schedule == "interval":
interval = config.get("interval_minutes", 60)
last_iso = last_run_map.get(name)
if not last_iso:
return True # Never run before
last_dt = datetime.fromisoformat(last_iso)
elapsed = (now - last_dt).total_seconds() / 60
return elapsed >= interval
else:
log(f"PLUGIN: Unknown schedule type '{schedule}' for {name}")
return False
def _next_due_str_plugin(config: dict, last_run_map: dict) -> str:
"""Calculate human-readable next due time for a plugin."""
now = datetime.now()
schedule = config["schedule"]
name = config["name"]
if schedule == "daily":
return f"daily @ {config.get('time', '00:00')}"
elif schedule == "hourly":
target_m = config.get("time", "0")
return f"hourly @ :{int(target_m):02d}"
elif schedule == "interval":
interval = config.get("interval_minutes", 60)
last_iso = last_run_map.get(name)
if last_iso:
last_dt = datetime.fromisoformat(last_iso)
next_dt = last_dt + timedelta(minutes=interval)
if next_dt <= now:
return "now"
return next_dt.strftime("%H:%M")
return "now"
return "unknown"
def _next_cron_run() -> str:
"""Calculate approximate next scheduler cron run time."""
now = datetime.now()
if now.minute < 30:
next_min = 30
next_hour = now.hour
else:
next_min = 0
next_hour = (now.hour + 1) % 24
return f"{next_hour:02d}:{next_min:02d}"
def process_plugins() -> dict:
"""
Discover and execute due plugins via wake script.
Each plugin declares its schedule in PLUGIN_CONFIG.
This function checks if each enabled plugin is due, then
dispatches it by calling the wake script with the plugin's branch and prompt.
Returns:
Dict with keys: discovered, enabled, executed, failed, errors,
executed_plugins (list of name->branch), skipped_plugins (list with next_due)
"""
results = {
"discovered": 0,
"enabled": 0,
"executed": 0,
"failed": 0,
"errors": [],
"executed_plugins": [],
"skipped_plugins": [],
}
if not PLUGINS_AVAILABLE:
log("PLUGIN: Plugin discovery not available, skipping")
return results
# Discover plugins
try:
plugins = discover_plugins()
except Exception as e:
log(f"PLUGIN: Discovery failed: {e}")
results["errors"].append(f"Plugin discovery: {e}")
return results
results["discovered"] = len(plugins)
log(f"PLUGIN: Discovered {len(plugins)} plugin(s)")
# Filter enabled
enabled = [p for p in plugins if p["config"].get("enabled", False)]
results["enabled"] = len(enabled)
if not enabled:
log("PLUGIN: No enabled plugins")
return results
# Load last-run timestamps
last_run_map = _load_last_run()
# Check each plugin
for plugin in enabled:
config = plugin["config"]
name = config["name"]
if not _is_plugin_due(config, last_run_map):
next_due = _next_due_str_plugin(config, last_run_map)
results["skipped_plugins"].append({
"name": name,
"branch": config.get("branch", "?"),
"next_due": next_due,
})
log(f"PLUGIN: {name} - not due, skipping")
continue
# Self-dispatching plugins handle their own branch targeting
if config.get("self_dispatch") and hasattr(plugin["module"], "run"):
log(f"PLUGIN: {name} - due, self-dispatching")
try:
run_result = plugin["module"].run()
run_status = run_result.get("status", "unknown")
if run_status in ("dispatched", "ready"):
target = run_result.get("branch", config.get("branch", "?"))
log(f"PLUGIN: {name} - self-dispatch OK -> {target}")
results["executed"] += 1
results["executed_plugins"].append({
"name": name,
"branch": target,
})
last_run_map[name] = datetime.now().isoformat()
else:
error_msg = run_result.get("error", run_result.get("message", "unknown"))
log(f"PLUGIN: {name} - self-dispatch failed: {error_msg}")
results["failed"] += 1
results["errors"].append(f"Plugin {name} self-dispatch: {error_msg}")
except Exception as e:
log(f"PLUGIN: {name} - self-dispatch error: {e}")
results["failed"] += 1
results["errors"].append(f"Plugin {name}: {e}")
time.sleep(1.0)
continue
# Check wake script availability
if not WAKE_SCRIPT or not WAKE_SCRIPT.exists():
log(f"PLUGIN: {name} - wake script not configured (set AIPASS_WAKE_SCRIPT)")
results["failed"] += 1
results["errors"].append(f"Plugin {name}: wake script not available")
continue
log(f"PLUGIN: {name} - due, dispatching to {config['branch']}")
# Build wake script command
cmd = [sys.executable, str(WAKE_SCRIPT), config["branch"]]
if config.get("prompt"):
cmd.append(config["prompt"])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
log(f"PLUGIN: {name} - wake script dispatched OK")
results["executed"] += 1
results["executed_plugins"].append({
"name": name,
"branch": config.get("branch", "?"),
})
# Record successful run
last_run_map[name] = datetime.now().isoformat()
else:
stderr_snippet = (result.stderr or "")[:200]
log(f"PLUGIN: {name} - wake script failed (rc={result.returncode}): {stderr_snippet}")
results["failed"] += 1
results["errors"].append(f"Plugin {name} wake rc={result.returncode}")
except subprocess.TimeoutExpired:
log(f"PLUGIN: {name} - wake script timed out (30s)")
results["failed"] += 1
results["errors"].append(f"Plugin {name} wake timeout")
except Exception as e:
log(f"PLUGIN: {name} - error: {e}")
results["failed"] += 1
results["errors"].append(f"Plugin {name}: {e}")
# Small delay between dispatches
time.sleep(1.0)
# Persist last-run timestamps
try:
_save_last_run(last_run_map)
except Exception as e:
log(f"PLUGIN: Failed to save last_run: {e}")
results["errors"].append(f"Save last_run: {e}")
return results
# =============================================
# ACTION REGISTRY PROCESSING (DPLAN-043)
# =============================================
def _ensure_registry() -> None:
"""Auto-migrate plugins to registry on first run if registry is empty."""
if not ACTION_REGISTRY_AVAILABLE:
return
registry = load_registry()
if not registry.get("actions"):
log("ACTION: Registry empty, auto-migrating plugins...")
count = migrate_plugins()
log(f"ACTION: Migrated {count} plugin(s) into registry")
def _dispatch_action(action: dict) -> dict:
"""
Dispatch a single action via wake script, self-dispatch, or email.
For plugin-backed actions: imports the plugin module, uses self_dispatch/run()
or dispatches via wake script.
For schedule actions: dispatches via wake script.
For reminder actions: sends email, then marks completed.
Returns:
Dict with 'status' ('ok'|'failed'|'skipped'), 'branch', and optional 'error'.
"""
action_type = action.get("type", "schedule")
name = action.get("name", "?")
target = action.get("target_branch", "")
# --- Plugin-backed actions: import plugin module for self-dispatch ---
if action_type == "plugin" and action.get("plugin_file"):
plugin_file = action["plugin_file"]
try:
module = importlib.import_module(f".plugins.{plugin_file}", package=__package__)
except Exception as e:
log(f"ACTION: {name} - failed to import plugin {plugin_file}: {e}")
return {"status": "failed", "branch": target, "error": str(e)}
# Self-dispatching plugins handle their own branch targeting
if action.get("self_dispatch") and hasattr(module, "run"):
log(f"ACTION: {name} - self-dispatching via plugin")
try:
run_result = module.run()
run_status = run_result.get("status", "unknown")
if run_status in ("dispatched", "ready", "skipped", "resolved", "reminded", "waiting"):
actual_target = run_result.get("branch", target)
log(f"ACTION: {name} - self-dispatch result: {run_status} -> {actual_target}")
return {"status": "ok", "branch": actual_target}
else:
error_msg = run_result.get("error", run_result.get("message", "unknown"))
log(f"ACTION: {name} - self-dispatch failed: {error_msg}")
return {"status": "failed", "branch": target, "error": error_msg}
except Exception as e:
log(f"ACTION: {name} - self-dispatch error: {e}")
return {"status": "failed", "branch": target, "error": str(e)}
# Standard plugin: check if it has a run() that returns "ready"
# then dispatch via wake script
if hasattr(module, "run"):
try:
run_result = module.run()
run_status = run_result.get("status", "unknown")
if run_status not in ("ready",):
log(f"ACTION: {name} - plugin run() returned: {run_status}")
if run_status in ("resolved", "waiting"):
return {"status": "ok", "branch": target}
return {"status": "failed", "branch": target, "error": f"run() returned {run_status}"}
except Exception as e:
log(f"ACTION: {name} - plugin run() error: {e}")
# Continue to wake script dispatch anyway
# --- Reminder actions: send email ---
if action_type == "reminder":
if not AI_MAIL_AVAILABLE:
log(f"ACTION: {name} - ai_mail not available for reminder")
return {"status": "failed", "branch": target, "error": "ai_mail not available"}
log(f"ACTION: {name} - reminder due, sending to {target}")
try:
email_sent = send_email_direct(
to_branch=target,
subject=f"[REMINDER] {name}",
message=action.get("prompt", name),
from_branch='@daemon',
auto_execute=True,
reply_to='@dev_central',
)
if email_sent:
mark_reminder_completed(action["id"])
log(f"ACTION: {name} - reminder sent and completed")
return {"status": "ok", "branch": target}
else:
log(f"ACTION: {name} - reminder email failed")
return {"status": "failed", "branch": target, "error": "email send returned False"}
except Exception as e:
log(f"ACTION: {name} - reminder error: {e}")
return {"status": "failed", "branch": target, "error": str(e)}
# --- Standard dispatch via wake script ---
if not WAKE_SCRIPT or not WAKE_SCRIPT.exists():
log(f"ACTION: {name} - wake script not configured (set AIPASS_WAKE_SCRIPT)")
return {"status": "failed", "branch": target, "error": "wake script not available"}
log(f"ACTION: {name} - dispatching to {target} via wake script")
cmd = [sys.executable, str(WAKE_SCRIPT)]
if action.get("fresh", True):
cmd.append("--fresh")
cmd.append(target)
if action.get("prompt"):
cmd.append(action["prompt"])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
log(f"ACTION: {name} - wake script dispatched OK")
return {"status": "ok", "branch": target}
else:
stderr_snippet = (result.stderr or "")[:200]
log(f"ACTION: {name} - wake script failed (rc={result.returncode}): {stderr_snippet}")
return {"status": "failed", "branch": target, "error": f"wake rc={result.returncode}"}
except subprocess.TimeoutExpired:
log(f"ACTION: {name} - wake script timed out (30s)")
return {"status": "failed", "branch": target, "error": "wake timeout"}
except Exception as e:
log(f"ACTION: {name} - dispatch error: {e}")
return {"status": "failed", "branch": target, "error": str(e)}
def process_actions() -> dict:
"""
Process all due actions from the registry.
Reads actions_registry.json, checks each enabled action for due status,
and dispatches via wake script, self-dispatch, or email.
Auto-migrates plugins to registry on first run.
Returns:
Dict with keys: total, enabled, executed, failed, errors,
executed_actions, skipped_actions.
"""
results = {
"total": 0,
"enabled": 0,
"executed": 0,
"failed": 0,
"errors": [],
"executed_actions": [],
"skipped_actions": [],
}
if not ACTION_REGISTRY_AVAILABLE:
log("ACTION: Action registry not available, skipping")
return results
# Auto-migrate if registry is empty
try:
_ensure_registry()
except Exception as e:
log(f"ACTION: Migration error: {e}")
results["errors"].append(f"Migration: {e}")
# Load registry
try:
registry = load_registry()
except Exception as e:
log(f"ACTION: Failed to load registry: {e}")
results["errors"].append(f"Load registry: {e}")
return results
actions = registry.get("actions", [])
results["total"] = len(actions)
log(f"ACTION: Registry has {len(actions)} action(s)")
# Filter enabled and not completed
enabled_actions = [
a for a in actions
if a.get("enabled", False) and not a.get("completed")
]
results["enabled"] = len(enabled_actions)
if not enabled_actions:
log("ACTION: No enabled actions")
return results
# Check each action
for action in enabled_actions:
action_id = action.get("id", "????")
name = action.get("name", "?")
if not is_action_due(action):
due_str = next_due_str(action)
results["skipped_actions"].append({
"id": action_id,
"name": name,
"branch": action.get("target_branch", "?"),
"next_due": due_str,
})
log(f"ACTION: {action_id} {name} - not due, next: {due_str}")
continue
# Dispatch the action
dispatch_result = _dispatch_action(action)
if dispatch_result["status"] == "ok":
results["executed"] += 1
results["executed_actions"].append({
"id": action_id,
"name": name,
"branch": dispatch_result.get("branch", "?"),
})
# Update last_run in registry
update_last_run(action_id)
else:
results["failed"] += 1
error_msg = dispatch_result.get("error", "unknown")
results["errors"].append(f"Action {action_id} {name}: {error_msg}")
# Small delay between dispatches
time.sleep(1.0)
return results
# =============================================
# MAIN
# =============================================
def main() -> int:
"""
Main cron entry point.
Returns:
0 on success, 1 on error
"""
log("=" * 60)
log("Scheduler cron triggered")
# Ensure lock directory exists
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
# Acquire single-instance lock (non-blocking, stdlib fcntl)
lock_fd = open(LOCK_FILE, "w", encoding="utf-8")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
log("Another instance already running, skipping.")
lock_fd.close()
return 0
try:
return _run_locked()
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def _run_locked() -> int:
"""Execute the cron job while holding the lock."""
exit_code = 0
# Step 1: Send "triggered" notification (optional)
if TELEGRAM_AVAILABLE:
try:
notify_triggered(EVENT_NAME)
log("Telegram: triggered notification sent")
except Exception as e:
log(f"WARNING: Telegram triggered notification failed: {e}")
# Step 2: Process due tasks
try:
results = process_due_tasks()
except Exception as e:
log(f"CRITICAL: Unhandled error in process_due_tasks: {e}")
if TELEGRAM_AVAILABLE:
try:
notify_error(EVENT_NAME, f"Unhandled error: {e}")
except Exception:
pass
return 1
# Step 2.5: Process actions from registry (replaces old process_plugins)
action_results = {
"total": 0, "enabled": 0, "executed": 0, "failed": 0,
"errors": [], "executed_actions": [], "skipped_actions": [],
}
try:
action_results = process_actions()
except Exception as e:
log(f"WARNING: Unhandled error in process_actions: {e}")
action_results["errors"].append(f"Action processing: {e}")
# Step 3: Build detailed summary
lines = []
# Tasks section
if results["recovered"]:
lines.append(f"Recovered {results['recovered']} stale dispatch(es)")
if results["due"] or results["success"]:
task_line = f"Tasks: {results['due']} due | {results['success']} sent"
if results["failed"]:
task_line += f" | {results['failed']} failed"
lines.append(task_line)
else:
lines.append("Tasks: none due")
# Actions section
executed = action_results.get("executed_actions", [])
skipped = action_results.get("skipped_actions", [])
if executed:
for a in executed:
lines.append(f" {a['id']} {a['name']} -> {a['branch']} OK")
if skipped:
for a in skipped:
lines.append(f" {a['id']} {a['name']} -> {a['branch']} (next: {a['next_due']})")
if not executed and not skipped:
lines.append("Actions: none enabled")
if action_results["failed"]:
lines.append(f"Action failures: {action_results['failed']}")
# Next run
lines.append(f"Next: ~{_next_cron_run()}")
summary = "\n".join(lines)
log(f"Results: {summary}")
# Step 4: Send completion or error notification (optional)
if TELEGRAM_AVAILABLE:
try:
if results["failed"] > 0 or results["errors"] or action_results["failed"] > 0 or action_results["errors"]:
error_detail = summary
all_errors = results["errors"] + action_results["errors"]
if all_errors:
error_detail += f"\nErrors:\n" + "\n".join(
f" - {e}" for e in all_errors[:5]
)
notify_error(EVENT_NAME, error_detail)
log("Telegram: error notification sent")
exit_code = 1
else:
notify_complete(EVENT_NAME, summary)
log("Telegram: complete notification sent")
except Exception as e:
log(f"WARNING: Telegram result notification failed: {e}")
log("Scheduler cron finished")
log("=" * 60)
return exit_code
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
# Last-resort catch -- never crash silently
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] FATAL: Unhandled exception: {e}", flush=True)
if TELEGRAM_AVAILABLE:
try:
notify_error(EVENT_NAME, f"FATAL: {e}")
except Exception:
pass
sys.exit(1)
+1
View File
@@ -0,0 +1 @@
# Tests package for assistant
+41
View File
@@ -0,0 +1,41 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: tests/conftest.py
# Date: 2025-11-08
# Version: 1.0.0
# Category: cortex/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2025-11-08): Initial implementation - Shared pytest fixtures
#
# CODE STANDARDS:
# - Error handling: Use error handler system (apps/handlers/error/)
# =============================================
"""Shared pytest fixtures for assistant tests"""
import pytest
import shutil
import tempfile
from pathlib import Path
from typing import Generator
@pytest.fixture
def temp_test_dir() -> Generator[Path, None, None]:
"""Creates temporary directory for testing, cleans up after"""
test_dir = Path(tempfile.mkdtemp())
yield test_dir
if test_dir.exists():
shutil.rmtree(test_dir)
@pytest.fixture
def sample_test_data() -> dict:
"""Provides sample test data
Customize this fixture for your module's needs
"""
return {
"test_key": "test_value",
"sample_data": "example"
}
@@ -0,0 +1,351 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_actions_registry.py - Action Registry Tests
# Date: 2026-03-02
# Version: 1.1.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.1.0 (2026-03-07): Adapted for AIPass public repo
# * Removed sys.path manipulation, uses package imports
# - v1.0.0 (2026-03-02): Initial creation - DPLAN-043 tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Temp dir isolation (no writes to real registry)
# =============================================
"""Tests for the action registry handler."""
import json
from datetime import datetime, timedelta
import pytest
from aipass.daemon.apps.handlers.actions import actions_registry as _reg_mod
create_action = _reg_mod.create_action
get_action = _reg_mod.get_action
list_actions = _reg_mod.list_actions
toggle_action = _reg_mod.toggle_action
delete_action = _reg_mod.delete_action
update_last_run = _reg_mod.update_last_run
mark_reminder_completed = _reg_mod.mark_reminder_completed
is_action_due = _reg_mod.is_action_due
calc_next_run = _reg_mod.calc_next_run
next_due_str = _reg_mod.next_due_str
@pytest.fixture(autouse=True)
def clean_registry(tmp_path):
"""Isolate REGISTRY_FILE to a temp dir for every test."""
test_registry = tmp_path / "actions_registry.json"
original = _reg_mod.REGISTRY_FILE
_reg_mod.REGISTRY_FILE = test_registry
yield test_registry
_reg_mod.REGISTRY_FILE = original
# =============================================
# CRUD TESTS
# =============================================
class TestCreate:
def test_create_action_basic(self, clean_registry):
"""Create a simple schedule action and verify fields."""
action = create_action(
name="test_audit",
action_type="schedule",
schedule_type="daily",
target_branch="@seed",
prompt="Run audit",
time="04:00",
fresh=True,
max_turns=20,
)
assert action["id"] == "0001"
assert action["name"] == "test_audit"
assert action["type"] == "schedule"
assert action["schedule_type"] == "daily"
assert action["time"] == "04:00"
assert action["target_branch"] == "@seed"
assert action["enabled"] is True
assert action["last_run"] is None
assert action["completed"] is None
def test_create_sequential_ids(self, clean_registry):
"""IDs should be sequential: 0001, 0002, 0003..."""
a1 = create_action(name="first", action_type="schedule", schedule_type="daily")
a2 = create_action(name="second", action_type="schedule", schedule_type="daily")
a3 = create_action(name="third", action_type="reminder", schedule_type="once")
assert a1["id"] == "0001"
assert a2["id"] == "0002"
assert a3["id"] == "0003"
def test_create_reminder(self, clean_registry):
"""Create a one-shot reminder action."""
action = create_action(
name="Check VERA progress",
action_type="reminder",
schedule_type="once",
target_branch="@dev_central",
prompt="Check VERA progress",
due_date="2026-03-11",
)
assert action["type"] == "reminder"
assert action["schedule_type"] == "once"
assert action["due_date"] == "2026-03-11"
def test_create_persists_to_json(self, clean_registry):
"""Action should be persisted to the JSON file."""
create_action(name="persisted", action_type="schedule", schedule_type="daily")
data = json.loads(clean_registry.read_text())
assert len(data["actions"]) == 1
assert data["actions"][0]["name"] == "persisted"
assert data["next_id"] == 2
class TestGet:
def test_get_existing(self, clean_registry):
"""Get an action by ID."""
create_action(name="findme", action_type="schedule", schedule_type="daily")
action = get_action("0001")
assert action is not None
assert action["name"] == "findme"
def test_get_missing(self, clean_registry):
"""Get returns None for nonexistent ID."""
assert get_action("9999") is None
class TestList:
def test_list_all(self, clean_registry):
"""List returns all non-completed actions."""
create_action(name="a", action_type="schedule", schedule_type="daily")
create_action(name="b", action_type="schedule", schedule_type="hourly")
actions = list_actions()
assert len(actions) == 2
def test_list_excludes_completed(self, clean_registry):
"""Completed reminders should be excluded by default."""
create_action(name="done", action_type="reminder", schedule_type="once",
due_date="2026-01-01")
mark_reminder_completed("0001")
assert len(list_actions()) == 0
assert len(list_actions(include_completed=True)) == 1
class TestToggle:
def test_toggle_off(self, clean_registry):
"""Toggle an action off."""
create_action(name="toggleme", action_type="schedule", schedule_type="daily")
assert toggle_action("0001", False) is True
action = get_action("0001")
assert action["enabled"] is False
def test_toggle_on(self, clean_registry):
"""Toggle an action back on."""
create_action(name="toggleme", action_type="schedule", schedule_type="daily",
enabled=False)
assert toggle_action("0001", True) is True
action = get_action("0001")
assert action["enabled"] is True
def test_toggle_missing(self, clean_registry):
"""Toggle returns False for nonexistent ID."""
assert toggle_action("9999", True) is False
class TestDelete:
def test_delete_existing(self, clean_registry):
"""Delete an action by ID."""
create_action(name="deleteme", action_type="schedule", schedule_type="daily")
assert delete_action("0001") is True
assert get_action("0001") is None
def test_delete_missing(self, clean_registry):
"""Delete returns False for nonexistent ID."""
assert delete_action("9999") is False
# =============================================
# DUE CHECKING TESTS
# =============================================
class TestIsDue:
def test_daily_due_at_correct_time(self, clean_registry):
"""Daily action is due when current time matches."""
now = datetime.now()
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": f"{now.hour:02d}:{now.minute:02d}",
"last_run": None,
}
assert is_action_due(action) is True
def test_daily_not_due_wrong_time(self, clean_registry):
"""Daily action is not due at wrong time."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": "99:99", # impossible time
"last_run": None,
}
assert is_action_due(action) is False
def test_daily_not_due_already_ran_today(self, clean_registry):
"""Daily action not due if already ran today."""
now = datetime.now()
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": f"{now.hour:02d}:{now.minute:02d}",
"last_run": now.isoformat(),
}
assert is_action_due(action) is False
def test_interval_due_never_run(self, clean_registry):
"""Interval action is due if never run before."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 60,
"last_run": None,
}
assert is_action_due(action) is True
def test_interval_due_enough_time_elapsed(self, clean_registry):
"""Interval action is due when enough time has passed."""
past = (datetime.now() - timedelta(minutes=120)).isoformat()
action = {
"enabled": True,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 60,
"last_run": past,
}
assert is_action_due(action) is True
def test_interval_not_due_too_soon(self, clean_registry):
"""Interval action is not due when too little time has passed."""
recent = (datetime.now() - timedelta(minutes=5)).isoformat()
action = {
"enabled": True,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 60,
"last_run": recent,
}
assert is_action_due(action) is False
def test_once_due_past_date(self, clean_registry):
"""Reminder is due when due_date is in the past."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "once",
"due_date": "2026-01-01",
}
assert is_action_due(action) is True
def test_once_not_due_future_date(self, clean_registry):
"""Reminder is not due when due_date is in the future."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "once",
"due_date": "2099-12-31",
}
assert is_action_due(action) is False
def test_disabled_never_due(self, clean_registry):
"""Disabled action is never due."""
action = {
"enabled": False,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 1,
"last_run": None,
}
assert is_action_due(action) is False
def test_completed_never_due(self, clean_registry):
"""Completed action is never due."""
action = {
"enabled": True,
"completed": "2026-03-01T12:00:00",
"schedule_type": "once",
"due_date": "2026-01-01",
}
assert is_action_due(action) is False
# =============================================
# NEXT RUN TESTS
# =============================================
class TestCalcNextRun:
def test_daily_next_run(self, clean_registry):
"""Daily action calculates next run correctly."""
action = {"schedule_type": "daily", "time": "04:00", "last_run": None}
result = calc_next_run(action)
assert result is not None
assert "04:00:00" in result
def test_interval_next_run(self, clean_registry):
"""Interval action calculates next run from last_run + interval."""
last = datetime.now().isoformat()
action = {"schedule_type": "interval", "interval_minutes": 60, "last_run": last}
result = calc_next_run(action)
assert result is not None
def test_once_next_run(self, clean_registry):
"""Reminder returns due_date as next run."""
action = {"schedule_type": "once", "due_date": "2026-03-11", "completed": None}
assert calc_next_run(action) == "2026-03-11"
class TestNextDueStr:
def test_daily_str(self, clean_registry):
action = {"schedule_type": "daily", "time": "04:00"}
assert next_due_str(action) == "daily @ 04:00"
def test_hourly_str(self, clean_registry):
action = {"schedule_type": "hourly", "time": "30"}
assert next_due_str(action) == "hourly @ :30"
def test_once_str(self, clean_registry):
action = {"schedule_type": "once", "due_date": "2026-03-11"}
assert next_due_str(action) == "2026-03-11"
# =============================================
# UPDATE TESTS
# =============================================
class TestUpdateLastRun:
def test_update_last_run(self, clean_registry):
"""Update last_run sets timestamp and recalculates next_run."""
create_action(name="test", action_type="schedule", schedule_type="interval",
interval_minutes=60)
ts = "2026-03-02T12:00:00"
assert update_last_run("0001", ts) is True
action = get_action("0001")
assert action["last_run"] == ts
assert action["next_run"] is not None
class TestMarkCompleted:
def test_mark_reminder_completed(self, clean_registry):
"""Marking a reminder completed sets completed timestamp and disables it."""
create_action(name="reminder", action_type="reminder", schedule_type="once",
due_date="2026-03-01")
assert mark_reminder_completed("0001") is True
action = get_action("0001")
assert action["completed"] is not None
assert action["enabled"] is False