feat(daemon): revive dormant citizen (revive-to-working ONLY) — scrub 6 stale @vera refs, add happy-path logger.info() central logging, fix Ruff/Introspection/Windows_Compat/Handler_Import/Imports (DPLAN-0203 night shift). 387 tests, seedgo 100%. Scheduler .daemon/ redesign deferred to DPLAN-0204

This commit is contained in:
AIOSAI
2026-06-12 01:29:58 -07:00
parent 8379f88fd7
commit ee004c4568
70 changed files with 12726 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Branch Prompt
AI context for `DAEMON`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch.
@@ -0,0 +1,56 @@
# DAEMON — Branch Context
<!-- File: src/aipass/daemon/.aipass/aipass_local_prompt.md — Injected on every prompt when in daemon directory. -->
Background scheduler and monitoring branch. Cron-triggered tasks, activity reports, action registry, scheduled follow-ups.
## Commands
```
drone @daemon # Introspection — list discovered modules
drone @daemon --help # Full help with all commands
drone @daemon update # Status digest of daemon activity
drone @daemon schedule list # List pending scheduled tasks
drone @daemon schedule create "task" --due 7d --to @branch
drone @daemon schedule run-due # Fire all due tasks (sends emails)
drone @daemon activity # Quick 24h activity summary
drone @daemon activity-report # Full detailed report (--json for raw)
drone @daemon branch-health BRANCH # Single branch deep dive
drone @daemon actions list # Action registry
drone @daemon actions set reminder 7d "msg" --to @branch
drone @daemon actions set schedule @branch "prompt" daily 04:00
```
Note: The `activity_report` module handles three commands: `activity`, `activity-report`, `branch-health`.
## Apps Layout
```
apps/
├── daemon.py # Entry point — module discovery + command routing
├── daemon_wakeup.py # Wakeup / cron trigger
├── scheduler_cron.py # Cron scheduler
├── modules/ # update, schedule, activity_report, actions, scheduler_ops, wakeup_ops
├── handlers/
│ ├── actions/ # actions_registry.py
│ ├── json/ # json_handler.py
│ ├── monitoring/ # activity_collector, memory_health, red_flag_detector, report_generator
│ ├── schedule/ # task_registry, assistant_notifier, telegram_notifier
│ ├── telegram/ # assistant_chat
│ └── update/ # data_loader
├── extensions/ # Extension point (empty)
└── plugins/ # botfather_reminder, community_rotation, daily_audit, dev_central_monitor, heartbeat
```
## Known Issues
- `activity_report` module shows as `activity_report` in `--help` but its actual commands are `activity`, `activity-report`, `branch-health` — calling `drone @daemon activity_report` fails
- `branch-health` expects uppercase branch names from registry; lowercase fails
- Secrets path: `~/.secrets/aipass/` (Path.home() / '.secrets' / 'aipass')
## Memory & Tracking
- `.trinity/passport.json` — identity
- `.trinity/local.json` — session history
- `.trinity/observations.json` — collaboration patterns
- `dev.local.md` — scratchpad for issues, todos, notes
- `DASHBOARD.local.json` — dashboard state
+5
View File
@@ -0,0 +1,5 @@
# Claude Code Settings
Claude Code configuration for `DAEMON`.
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.pyc
*.pyo
.env
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
dist/
build/
*.log
*.tmp
*.swp
+5
View File
@@ -0,0 +1,5 @@
# Standards Bypass
Seedgo audit bypass config for `DAEMON`.
When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified.
+152
View File
@@ -0,0 +1,152 @@
{
"metadata": {
"version": "1.0.0",
"created": "2026-03-07T23:23:54.295205",
"description": "Standards bypass configuration for this branch"
},
"bypass": [
{
"file": "apps/scheduler_cron.py",
"standard": "naming",
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function references assigned at module level from task_registry imports, not constants",
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
},
{
"file": "apps/modules/scheduler_ops.py",
"standard": "naming",
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function re-exports from task_registry, not constants",
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
},
{
"file": "apps/modules/schedule.py",
"standard": "naming",
"reason": "False positive: send_email_direct is a function alias assigned at module level, not a constant",
"pattern": "send_email_direct"
},
{
"file": "apps/handlers/monitoring/memory_health.py",
"standard": "naming",
"reason": "False positive: trinity_dir, required_checks, optional_checks are local variables inside functions, not module-level constants",
"pattern": "trinity_dir|required_checks|optional_checks"
},
{
"file": "apps/handlers/monitoring/activity_collector.py",
"standard": "naming",
"reason": "False positive: all_files, last_activity are local variables inside functions, not module-level constants",
"pattern": "all_files|last_activity"
},
{
"file": "apps/handlers/monitoring/red_flag_detector.py",
"standard": "naming",
"reason": "False positive: result, time_diff, counts are local variables inside functions, not module-level constants",
"pattern": "result|time_diff|counts"
},
{
"file": "apps/handlers/monitoring/report_generator.py",
"standard": "naming",
"reason": "False positive: red_flags, activity, health_data are local variables inside functions, not module-level constants",
"pattern": "red_flags|activity|health_data"
},
{
"file": "apps/handlers/schedule/task_registry.py",
"standard": "naming",
"reason": "False positive: task_result, email_body, to_branch are local variables inside functions, not module-level constants",
"pattern": "task_result|email_body|to_branch"
},
{
"file": "apps/handlers/actions/actions_registry.py",
"standard": "naming",
"reason": "False positive: action is a local variable inside functions, not a module-level constant. File name matches its domain (actions/actions_registry.py) — renaming would lose specificity",
"pattern": "action|actions_registry"
},
{
"file": "apps/handlers/actions/action_processor.py",
"standard": "naming",
"reason": "False positive: load_registry, is_action_due, update_last_run are function references from try/except import fallback, not constants",
"pattern": "load_registry|is_action_due|update_last_run"
},
{
"file": "apps/scheduler_cron.py",
"standard": "architecture",
"reason": "Entry point script — lives in apps/ root by design, not a module or handler",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/daemon_wakeup.py",
"standard": "architecture",
"reason": "Entry point script — lives in apps/ root by design, not a module or handler",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/daemon.py",
"standard": "architecture",
"reason": "Branch entry point — lives in apps/ root by design per AIPass convention",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/scheduler_cron.py",
"standard": "encapsulation",
"reason": "scheduler_cron.py is itself an entry point script that directly uses handler functions — not a module violating encapsulation",
"pattern": "Handler imported directly"
},
{
"file": "apps/handlers/schedule/task_registry.py",
"standard": "unused_function",
"reason": "Public API functions used by tests (test_task_registry.py) and available for external callers",
"pattern": "get_task_by_id|get_pending_tasks"
},
{
"file": "apps/plugins/__init__.py",
"standard": "unused_function",
"reason": "Public API for plugin discovery — used by scheduler_cron and available for external callers. Referenced in help text.",
"pattern": "discover_plugins"
},
{
"file": "apps/modules/activity_report.py",
"standard": "introspection",
"reason": "activity, activity-report, activity_report commands all work with no args (default 24h). No-args introspection gate would break valid no-arg invocations.",
"pattern": "no-args gate"
},
{
"file": "apps/modules/update.py",
"standard": "introspection",
"reason": "update runs the status digest with no args — that IS its primary function. Showing introspection instead was reported as a dead-end UX bug (DPLAN-0085).",
"pattern": "no-args gate"
},
{
"file": "apps/plugins/heartbeat.py",
"standard": "architecture",
"reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/plugins/daily_audit.py",
"standard": "architecture",
"reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/plugins/community_rotation.py",
"standard": "architecture",
"reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.",
"pattern": "File not in standard 3-layer structure"
}
],
"notes": {
"usage": "Add entries to 'bypass' list to exclude specific violations",
"example": {
"file": "apps/modules/logger.py",
"standard": "cli",
"reason": "Circular dependency - logger cannot import CLI",
"lines": [146, 177],
"pattern": "if __name__ == '__main__'"
},
"fields": {
"file": "Relative path from branch root (required)",
"standard": "Standard name: cli, imports, naming, etc. (required)",
"lines": "Optional - specific line numbers to bypass",
"pattern": "Optional - pattern to match (e.g. 'if __name__')",
"reason": "Required - why this bypass exists"
}
}
}
+170
View File
@@ -0,0 +1,170 @@
[← Back to AIPass](../../../README.md)
# DAEMON
**Purpose:** Cron-triggered task scheduler with plugin system. Routes commands to modules for scheduled tasks, activity reports, action management, and status digests.
**Module:** `aipass.daemon`
**Created:** 2026-03-07
**Citizen Class:** builder
**Last Updated:** 2026-04-07
---
## Overview
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
### What I Do
- Route CLI commands to discovered modules (update, schedule, activity_report, actions)
- Manage scheduled follow-ups with CRUD operations and due-date processing
- Generate activity reports across all branches (24h summary, detailed, per-branch)
- Run action registry (list, toggle, set reminder/schedule, migrate plugins)
- Auto-discover and dispatch plugins (community_rotation, daily_audit, heartbeat)
- Detect red flags (code changes without memory updates, stale branches)
- Produce status digests (inbox, actionable items, escalations)
---
## Architecture
```
daemon/
├── __init__.py
├── README.md
├── DASHBOARD.local.json
├── apps/
│ ├── daemon.py # Entry point (CLI) — module discovery + command routing
│ ├── daemon_wakeup.py # Wakeup / cron trigger
│ ├── scheduler_cron.py # Cron scheduler
│ ├── modules/
│ │ ├── update.py # Status digest module — summarizes DAEMON activity
│ │ ├── schedule.py # Scheduled follow-ups — fire-and-forget task management
│ │ ├── activity_report.py # Branch activity report generator
│ │ ├── actions.py # Action registry CLI — list, toggle, info, reminders
│ │ ├── scheduler_ops.py # Scheduler cron operations facade
│ │ └── wakeup_ops.py # Wake-up cron operations facade
│ ├── handlers/
│ │ ├── actions/
│ │ │ └── actions_registry.py # Action registry implementation
│ │ ├── json/
│ │ │ └── json_handler.py # JSON data operations
│ │ ├── monitoring/
│ │ │ ├── activity_collector.py # Collects branch activity data
│ │ │ ├── memory_health.py # Memory health checks
│ │ │ └── red_flag_detector.py # Detects anomalies / red flags
│ │ ├── schedule/
│ │ │ ├── task_registry.py # Task registry for scheduled items
│ │ │ └── .archive/ # assistant_notifier, telegram_notifier (archived)
│ │ ├── telegram/ # ARCHIVED — moving to skills system
│ │ │ └── .archive/ # assistant_chat (archived)
│ │ └── update/
│ │ └── data_loader.py # Data loading for status digests
│ ├── extensions/ # Extension point for additional capabilities
│ ├── json_templates/ # JSON template definitions
│ └── plugins/
│ ├── community_rotation.py # Community rotation plugin
│ ├── daily_audit.py # Daily audit plugin
│ ├── heartbeat.py # Heartbeat / liveness plugin
│ └── .archive/ # botfather_reminder, devpulse_monitor (archived)
├── daemon_json/ # JSON tracking data
├── docs/ # Documentation
├── dropbox/ # Incoming file drops
├── logs/ # Prax log output
├── tools/ # Branch verification utilities
└── tests/ # Test suite
```
---
## Commands / Usage
```bash
drone @daemon # Show discovered modules (introspection)
drone @daemon --help # Rich-formatted help with all commands
drone @daemon --version # Print version
drone @daemon update # Status digest — inbox, session info, escalations (partial — reads stale data paths)
drone @daemon schedule list # List pending scheduled tasks
drone @daemon schedule create "task" --due 7d --to @branch
drone @daemon schedule run-due # Fire all due tasks (sends emails)
drone @daemon activity # Quick 24h activity summary
drone @daemon activity-report # Full detailed report (--json for raw)
drone @daemon branch-health BRANCH # Single branch deep dive
drone @daemon actions list # Action registry
drone @daemon actions <id> on/off # Toggle action
drone @daemon actions set reminder 7d "msg" --to @branch
drone @daemon actions set schedule @branch "prompt" daily 04:00
```
Each module accepts `--help` for module-specific usage:
```bash
drone @daemon <command> --help
```
---
## Modules
| Module | Description | Status |
|--------|-------------|--------|
| `update` | Status digest of DAEMON activity | *(partial)* — reads inbox/sessions but data_loader paths return empty |
| `schedule` | Fire-and-forget scheduled follow-ups and task management | Operational |
| `activity_report` | Branch activity reports: `activity`, `activity-report`, `branch-health` | Operational |
| `actions` | Action registry CLI — list, toggle, info, set reminder, set schedule, migrate | Operational |
| `scheduler_ops` | Scheduler cron operations facade for scheduler_cron.py | Operational |
| `wakeup_ops` | Wake-up cron operations facade for daemon_wakeup.py | Operational |
---
## Integration Points
### Depends On
- `rich` -- Console output and formatted display
- Python stdlib (`sys`, `typing`, `logging`)
### Provides To
- All modules — background task scheduling, activity monitoring, action tracking
- Plugins — extensible plugin system for recurring tasks (community_rotation, daily_audit, heartbeat)
- Note: Telegram handlers archived — moving to skills system. See `apps/handlers/telegram/.archive/`
---
## Plugins
| Plugin | Target | Schedule | Status |
|--------|--------|----------|--------|
| `community_rotation` | @rotating | every 4h | Operational — requires AIPASS_WAKE_SCRIPT env var |
| `daily_audit` | @seed | daily 04:00 | *(not operational)* — targets @seed (renamed to @seedgo) |
| `heartbeat` | @vera | every 4h | *(not operational)* — @vera not in branch registry |
---
## Known Issues
- `update` command shows empty data (0 sessions, no focus) — data_loader reads from different paths than .trinity/local.json
- `daily_audit` plugin targets `@seed` which was renamed to `@seedgo`
- `heartbeat` plugin targets `@vera` which is not registered in the branch registry
- All plugins require `AIPASS_WAKE_SCRIPT` env var to dispatch — without it, plugins discover but can't execute
- `drone @daemon activity_report` (underscore) fails — use `activity`, `activity-report`, or `branch-health` instead
---
## Identity
- **Passport:** `.trinity/passport.json`
- **Session History:** `.trinity/local.json`
- **Observations:** `.trinity/observations.json`
- **Branch Prompt:** `.aipass/branch_system_prompt.md`
---
## Test Suite
- **387 tests** across 16 test files
- 8/8 modules covered, 43/51 public functions tested
- Seedgo audit: **100%** across all standards
*Last Updated: 2026-04-07*
---
[← Back to AIPass](../../../README.md)
View File
+8
View File
@@ -0,0 +1,8 @@
# Apps
Application layer for `DAEMON`.
- `daemon.py` — Entry point. Auto-discovers and routes commands to modules.
- `modules/` — Business logic and orchestration. One module per command.
- `handlers/` — Implementation details. Called by modules, never by CLI directly.
- `plugins/` — Scheduled tasks and extensions.
+2
View File
@@ -0,0 +1,2 @@
# Apps package - Branch application modules and handlers
from . import handlers # noqa: F401
+214
View File
@@ -0,0 +1,214 @@
# =================== AIPass ====================
# Name: daemon.py
# Description: Entry point CLI for drone @daemon
# Version: 1.0.0
# Created: 2026-03-08
# Modified: 2026-03-08
# =============================================
"""
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
from aipass.prax.apps.modules.logger import system_logger as logger
# Console
from aipass.cli.apps.modules import console, error
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.modules import update, schedule, activity_report, actions
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
# =============================================================================
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()
# Show actual routable commands, not module names
_COMMAND_HELP = [
("update", "Returns digest of DAEMON activity for check-ins."),
("schedule", "CLI interface for fire-and-forget scheduled follow-ups."),
("activity", "Quick 24-hour activity summary."),
("activity-report", "Full detailed activity report (--json for raw)."),
("branch-health", "Single branch deep dive (e.g., branch-health DAEMON)."),
("actions", "CLI interface for the numbered action registry."),
]
for cmd_name, desc in _COMMAND_HELP:
console.print(f" [green]{cmd_name:20}[/green] [dim]{desc}[/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 []
json_handler.log_operation("daemon_command", {"command": command})
# Route to modules
if route_command(command, remaining_args, modules):
logger.info("[DAEMON] Command routed successfully: %s", command)
return 0
else:
console.print()
error(f"Unknown command: {command}", suggestion="Run 'daemon --help' for available commands")
console.print()
return 1
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
logger.warning("DAEMON operation cancelled by user (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)
+270
View File
@@ -0,0 +1,270 @@
# =================== AIPass ====================
# Name: daemon_wakeup.py
# Description: DAEMON Wake-Up Cron Trigger
# Version: 1.0.0
# Created: 2026-02-15
# Modified: 2026-03-10
# =============================================
"""
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. Check daemon's email inbox (new/opened counts)
3. Build summary report with sender/subject listings
4. Log report
"""
# =============================================
# IMPORTS
# =============================================
import sys
import json
from pathlib import Path
from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
try:
import fcntl
except ImportError:
fcntl = None # type: ignore[assignment]
logger.info("[DAEMON] daemon_wakeup: fcntl unavailable (Windows)")
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[1] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
LOCK_FILE = JSON_DIR / "wakeup.lock"
INBOX_PATH = _DAEMON_ROOT / "ai_mail.local" / "inbox.json"
# =============================================
# LOGGING
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]daemon_wakeup Module[/bold cyan]")
console.print()
console.print("[dim]Cron trigger for daemon wake-up inbox checking and reporting[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" modules/")
console.print(" [cyan]*[/cyan] wakeup_ops.py [dim](notifications archived — Telegram removed)[/dim]")
console.print()
def print_help() -> None:
"""Display usage information for daemon_wakeup."""
console.print("\n[bold cyan]daemon_wakeup.py - DAEMON Wake-Up Cron Trigger[/bold cyan]")
console.print("\n[yellow]USAGE:[/yellow]")
console.print(" drone @daemon daemon_wakeup Run the wake-up checker")
console.print(" drone @daemon daemon_wakeup --help Show this help message")
console.print("\n[yellow]DESCRIPTION:[/yellow]")
console.print(" Checks daemon's email inbox and sends summary reports.")
console.print(" Intended to be called periodically by cron.")
console.print()
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")
console.print(f"[{timestamp}] {message}")
# =============================================
# 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:
logger.warning(f"Failed to read inbox: {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
"""
args = sys.argv[1:]
if not args:
print_introspection()
return 0
if args[0] in ["--version", "-V"]:
console.print("daemon_wakeup v1.0.0")
return 0
if args[0] in ["--help", "-h"]:
print_help()
sys.exit(0)
json_handler.log_operation("wakeup_triggered")
log("=" * 60)
log("Daemon wake-up triggered")
# Ensure lock directory exists
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
# Acquire single-instance lock (non-blocking, stdlib fcntl)
if fcntl is None:
log("fcntl not available (non-Unix platform), skipping lock.")
return _run_locked()
lock_fd = open(LOCK_FILE, "w", encoding="utf-8")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
logger.warning(f"Wakeup lock acquisition failed (another instance running): {e}")
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: Check inbox
try:
inbox = check_inbox()
log(f"Inbox: {inbox['new_count']} new, {inbox['opened_count']} opened")
except Exception as e:
logger.error(f"Unhandled error in check_inbox: {e}", exc_info=True)
log(f"CRITICAL: Unhandled error in check_inbox: {e}")
return 1
# Step 2: Build report
report = build_report(inbox)
log(f"Report: {report.splitlines()[0]}")
log("Daemon wake-up finished")
logger.info("[DAEMON] daemon_wakeup: Wake-up cycle completed successfully")
log("=" * 60)
return exit_code
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
# Last-resort catch -- never crash silently
logger.error(f"FATAL wakeup exception: {e}", exc_info=True)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[{timestamp}] FATAL: Unhandled exception: {e}")
sys.exit(1)
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
@@ -0,0 +1,5 @@
# Handlers
Implementation details for `DAEMON`.
Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers.
+115
View File
@@ -0,0 +1,115 @@
"""Daemon handlers package - Security protected."""
import inspect
from pathlib import Path
MY_BRANCH = "aipass.daemon"
def _find_real_caller():
"""
Walk the stack to find the actual file that triggered this import.
Skips:
- This file (handlers/__init__.py)
- Python's importlib internals
- Frozen modules
Returns tuple: (file_path, import_line) or (None, None)
"""
stack = inspect.stack()
this_file = str(Path(__file__).resolve())
for frame_info in stack:
filename = frame_info.filename
# Skip this file
if this_file in str(Path(filename).resolve()):
continue
# Skip Python internals
if filename.startswith("<") or "importlib" in filename:
continue
# Found a real file - try to get the import line
import_line = None
if frame_info.code_context:
import_line = frame_info.code_context[0].strip()
return str(Path(filename).resolve()), import_line
return None, None
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
return parts[i + 1]
return "unknown"
def _guard_branch_access():
"""
Block cross-branch handler imports.
Only code from within the 'daemon' branch can import these handlers.
External branches must use aipass.daemon.apps.modules instead.
"""
caller_file, import_line = _find_real_caller()
# DEBUG: Print what we found
import os
if os.environ.get("AIPASS_DEBUG_GUARD"):
import sys
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
if caller_file is None:
# Can't determine caller from real files
# Check if we're being run from command line (external)
# by looking at the raw stack for <string> or <stdin>
stack = inspect.stack()
for frame in stack:
if frame.filename in ("<string>", "<stdin>"):
return # Allow command-line Python through
return # Allow if truly can't determine
# Check if caller is from our branch
# MY_BRANCH is "aipass.daemon" (dotted), but filesystem uses "/aipass/daemon/"
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
if branch_path in caller_file:
return # Same branch, allowed
# External caller - block access
caller_branch = _extract_branch_name(caller_file)
caller_filename = Path(caller_file).name
blocked_import = import_line if import_line else "unknown"
raise ImportError(
f"\n{'=' * 60}\n"
f"ACCESS DENIED: Cross-branch handler import blocked\n"
f"{'=' * 60}\n"
f" Caller branch: {caller_branch}\n"
f" Caller file: {caller_filename}\n"
f" Blocked: {blocked_import}\n"
f"\n"
f" Handlers are internal to their branch.\n"
f" Use the module API instead:\n"
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
f"\n"
f" Example:\n"
f" from {MY_BRANCH}.apps.modules.logger import logger\n"
f"\n"
f" For full standards guide:\n"
f" drone @seedgo handlers\n"
f"{'=' * 60}"
)
# Run guard at import time
_guard_branch_access()
@@ -0,0 +1,349 @@
# =================== AIPass ====================
# Name: action_processor.py
# Description: Action registry scheduling and dispatch processor
# Version: 1.0.0
# Created: 2026-03-24
# Modified: 2026-03-24
# =============================================
"""
Action registry scheduling and dispatch processor.
Extracted from scheduler_cron.py to decouple action processing from the
main scheduler loop. All three public functions accept injectable log_fn
and send_email_fn callables so the module can be driven from any caller
without hard-wiring daemon-specific helpers.
"""
import sys
import time
import importlib
import subprocess
from pathlib import Path
from typing import Dict, Any, Callable
import os
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.daemon.apps.handlers.json import json_handler
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
WAKE_SCRIPT = Path(os.environ.get("AIPASS_WAKE_SCRIPT", ""))
AI_MAIL_AVAILABLE = True
# ---------------------------------------------------------------------------
# Optional actions_registry imports (sibling handler)
# ---------------------------------------------------------------------------
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 as e:
logger.info(f"Optional dependency not available: actions_registry ({e})")
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
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _run_normal_plugin(module, name: str, target: str, log_fn: Callable | None = None) -> dict | None:
"""Run a normal (non-self-dispatch) plugin. Returns result dict or None to continue."""
try:
run_result = module.run()
run_status = run_result.get("status", "unknown")
if run_status in ("ready",):
return None
_log(f"ACTION: {name} - plugin run() returned: {run_status}", log_fn)
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:
logger.warning(f"Action {name} plugin run() error: {e}")
_log(f"ACTION: {name} - plugin run() error: {e}", log_fn)
return None
def _log(msg: str, log_fn: Callable | None = None) -> None:
"""Route a message through the caller-supplied log function or logger.info."""
if log_fn is not None:
log_fn(msg)
else:
logger.info(msg)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def _ensure_registry(log_fn: Callable | None = None) -> None:
"""Auto-migrate plugins to registry on first run if registry is empty."""
if not ACTION_REGISTRY_AVAILABLE:
return
assert load_registry is not None
assert migrate_plugins is not None
registry = load_registry()
if not registry.get("actions"):
_log("ACTION: Registry empty, auto-migrating plugins...", log_fn)
count = migrate_plugins()
_log(f"ACTION: Migrated {count} plugin(s) into registry", log_fn)
def _dispatch_action(
action: dict,
log_fn: Callable | None = None,
send_email_fn: Callable | None = None,
) -> dict:
"""
Dispatch a single action based on its type (plugin / reminder / schedule).
Returns a dict with at least ``{"status": "ok"|"failed", "branch": ...}``.
"""
action_type = action.get("type", "schedule")
name = action.get("name", "?")
target = action.get("target_branch", "")
# ---- plugin actions ----
if action_type == "plugin" and action.get("plugin_file"):
plugin_file = action["plugin_file"]
try:
module = importlib.import_module(f"aipass.daemon.apps.plugins.{plugin_file}")
except Exception as e:
logger.error(f"Action {name} failed to import plugin {plugin_file}: {e}")
_log(f"ACTION: {name} - failed to import plugin {plugin_file}: {e}", log_fn)
return {"status": "failed", "branch": target, "error": str(e)}
# Self-dispatching plugins
if action.get("self_dispatch") and hasattr(module, "run"):
_log(f"ACTION: {name} - self-dispatching via plugin", log_fn)
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}",
log_fn,
)
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}", log_fn)
return {"status": "failed", "branch": target, "error": error_msg}
except Exception as e:
logger.error(f"Action {name} self-dispatch error: {e}")
_log(f"ACTION: {name} - self-dispatch error: {e}", log_fn)
return {"status": "failed", "branch": target, "error": str(e)}
# Normal plugin run()
if hasattr(module, "run"):
result = _run_normal_plugin(module, name, target, log_fn)
if result is not None:
return result
# ---- reminder actions ----
if action_type == "reminder":
if not AI_MAIL_AVAILABLE:
_log(f"ACTION: {name} - ai_mail not available for reminder", log_fn)
return {"status": "failed", "branch": target, "error": "ai_mail not available"}
if send_email_fn is None:
_log(f"ACTION: {name} - email not configured for reminder", log_fn)
return {"status": "failed", "branch": target, "error": "email not configured"}
_log(f"ACTION: {name} - reminder due, sending to {target}", log_fn)
try:
email_sent = send_email_fn(
to_branch=target,
subject=f"[REMINDER] {name}",
message=action.get("prompt", name),
from_branch="@daemon",
auto_execute=True,
reply_to="@devpulse",
)
if email_sent:
assert mark_reminder_completed is not None
mark_reminder_completed(action["id"])
_log(f"ACTION: {name} - reminder sent and completed", log_fn)
return {"status": "ok", "branch": target}
else:
_log(f"ACTION: {name} - reminder email failed", log_fn)
return {
"status": "failed",
"branch": target,
"error": "email send returned False",
}
except Exception as e:
logger.error(f"Action {name} reminder error: {e}")
_log(f"ACTION: {name} - reminder error: {e}", log_fn)
return {"status": "failed", "branch": target, "error": str(e)}
# ---- schedule (wake-script) actions ----
if not WAKE_SCRIPT or not WAKE_SCRIPT.exists():
_log(
f"ACTION: {name} - wake script not configured (set AIPASS_WAKE_SCRIPT)",
log_fn,
)
return {"status": "failed", "branch": target, "error": "wake script not available"}
_log(f"ACTION: {name} - dispatching to {target} via wake script", log_fn)
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", log_fn)
return {"status": "ok", "branch": target}
else:
stderr_snippet = (result.stderr or "")[:200]
_log(
f"ACTION: {name} - wake script failed (rc={result.returncode}): {stderr_snippet}",
log_fn,
)
return {
"status": "failed",
"branch": target,
"error": f"wake rc={result.returncode}",
}
except subprocess.TimeoutExpired:
logger.warning(f"Action {name} wake script timed out (30s)")
_log(f"ACTION: {name} - wake script timed out (30s)", log_fn)
return {"status": "failed", "branch": target, "error": "wake timeout"}
except Exception as e:
logger.error(f"Action {name} dispatch error: {e}")
_log(f"ACTION: {name} - dispatch error: {e}", log_fn)
return {"status": "failed", "branch": target, "error": str(e)}
def process_actions(
log_fn: Callable | None = None,
send_email_fn: Callable | None = None,
) -> Dict[str, Any]:
"""
Walk the action registry, dispatch every enabled-and-due action.
Returns a summary dict with counts, errors, and per-action details.
"""
results: Dict[str, Any] = {
"total": 0,
"enabled": 0,
"executed": 0,
"failed": 0,
"errors": [],
"executed_actions": [],
"skipped_actions": [],
}
json_handler.log_operation("process_actions")
if not ACTION_REGISTRY_AVAILABLE:
_log("ACTION: Action registry not available, skipping", log_fn)
return results
assert load_registry is not None
assert is_action_due is not None
assert next_due_str is not None
assert update_last_run is not None
# --- ensure registry is populated ---
try:
_ensure_registry(log_fn)
except Exception as e:
logger.warning(f"Action registry migration error: {e}")
_log(f"ACTION: Migration error: {e}", log_fn)
results["errors"].append(f"Migration: {e}")
# --- load registry ---
try:
registry = load_registry()
except Exception as e:
logger.error(f"Failed to load action registry: {e}")
_log(f"ACTION: Failed to load registry: {e}", log_fn)
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)", log_fn)
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", log_fn)
return results
# --- dispatch loop ---
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}", log_fn)
continue
dispatch_result = _dispatch_action(action, log_fn, send_email_fn)
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(action_id)
else:
results["failed"] += 1
error_msg = dispatch_result.get("error", "unknown")
results["errors"].append(f"Action {action_id} {name}: {error_msg}")
time.sleep(1.0)
return results
@@ -0,0 +1,550 @@
# =================== AIPass ====================
# Name: actions_registry.py
# Description: Numbered Action Registry
# Version: 1.0.0
# Created: 2026-03-02
# Modified: 2026-03-02
# =============================================
"""
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
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# logger imported from aipass.prax
# Paths
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # 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., "@seedgo")
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)
json_handler.log_operation("action_registry_modified", {"action": name})
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 _already_ran_today(action: dict, now: datetime) -> bool:
"""Check if a daily action already ran today."""
last_run = action.get("last_run")
if not last_run:
return False
try:
last_dt = datetime.fromisoformat(last_run)
return last_dt.date() == now.date()
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Daily last_run parse failed: %s", e)
return False
def _already_ran_this_hour(action: dict, now: datetime) -> bool:
"""Check if an hourly action already ran this hour."""
last_run = action.get("last_run")
if not last_run:
return False
try:
last_dt = datetime.fromisoformat(last_run)
return last_dt.hour == now.hour and last_dt.date() == now.date()
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Hourly last_run parse failed: %s", e)
return False
def _is_daily_due(action: dict, now: datetime) -> bool:
"""Check if a daily action is due."""
target_time = action.get("time", "00:00")
try:
target_h, target_m = map(int, target_time.split(":"))
except (ValueError, AttributeError) as e:
logger.info("[actions_registry] Daily time parse failed for %r: %s", target_time, e)
return False
current_minutes = now.hour * 60 + now.minute
target_minutes = target_h * 60 + target_m
minutes_diff = abs(current_minutes - target_minutes)
minutes_diff = min(minutes_diff, 1440 - minutes_diff)
if minutes_diff > 15:
return False
return not _already_ran_today(action, now)
def _is_hourly_due(action: dict, now: datetime) -> bool:
"""Check if an hourly action is due."""
target_m_str = action.get("time", "0")
try:
target_m = int(target_m_str)
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Hourly time parse failed for %r: %s", target_m_str, e)
return False
minutes_diff = abs(now.minute - target_m)
minutes_diff = min(minutes_diff, 60 - minutes_diff)
if minutes_diff > 15:
return False
return not _already_ran_this_hour(action, now)
def _is_interval_due(action: dict, now: datetime) -> bool:
"""Check if an interval action is due."""
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) as e:
logger.info("[actions_registry] Interval last_run parse failed: %s", e)
return True
def _is_once_due(action: dict, now: datetime) -> bool:
"""Check if a one-shot reminder action is due."""
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) as e:
logger.info("[actions_registry] Once due_date parse failed for %r: %s", due_date, e)
return False
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", "")
_due_checkers = {
"daily": _is_daily_due,
"hourly": _is_hourly_due,
"interval": _is_interval_due,
"once": _is_once_due,
}
checker = _due_checkers.get(schedule_type)
if checker is None:
return False
return checker(action, now)
def _calc_next_daily(action: dict, now: datetime) -> Optional[str]:
"""Calculate next run for a daily action."""
target_time = action.get("time", "00:00")
try:
target_h, target_m = map(int, target_time.split(":"))
except (ValueError, AttributeError) as e:
logger.info("[actions_registry] calc_next_run daily time parse failed: %s", e)
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()
def _calc_next_hourly(action: dict, now: datetime) -> Optional[str]:
"""Calculate next run for an hourly action."""
target_m_str = action.get("time", "0")
try:
target_m = int(target_m_str)
except (ValueError, TypeError) as e:
logger.info("[actions_registry] calc_next_run hourly time parse failed: %s", e)
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()
def _calc_next_interval(action: dict, now: datetime) -> Optional[str]:
"""Calculate next run for an interval action."""
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) as e:
logger.info("[actions_registry] calc_next_run interval last_run parse failed: %s", e)
return now.isoformat()
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":
return _calc_next_daily(action, now)
if schedule_type == "hourly":
return _calc_next_hourly(action, now)
if schedule_type == "interval":
return _calc_next_interval(action, now)
if 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_interval(action: dict) -> str:
"""Human-readable next due string for interval actions."""
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if not last_run:
return "now"
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) as e:
logger.info("[actions_registry] next_due_str interval last_run parse failed: %s", e)
return "now"
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')}"
if schedule_type == "hourly":
m = action.get("time", "0")
return f"hourly @ :{int(m):02d}"
if schedule_type == "interval":
return _next_due_interval(action)
if 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) as e:
logger.warning("[actions_registry] Failed to load last_run.json: %s", e)
# 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 daemon branch"""
@@ -0,0 +1,246 @@
# =================== AIPass ====================
# Name: json_handler.py
# Description: JSON Auto-Creating Handler
# Version: 1.2.0
# Created: 2025-11-21
# Modified: 2026-01-29
# =============================================
"""
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
from aipass.prax import logger
# Constants
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
MAX_LOG_ENTRIES = 100 # Default FIFO limit for log_operation (overridable via config)
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 _default_template(json_type: str, module_name: str) -> Any:
"""Return inline default structure for a JSON type."""
current_date = datetime.now().date().isoformat()
if json_type == "config":
return {
"module_name": module_name,
"version": "1.0.0",
"timestamp": current_date,
"config": {"auto_save": True, "enabled": True},
}
elif json_type == "data":
return {
"module_name": module_name,
"created": current_date,
"last_updated": current_date,
"operations_total": 0,
}
elif json_type == "log":
return []
raise ValueError(f"Unknown json_type: {json_type}")
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 as e:
logger.warning("[json_handler] Corrupted JSON file %s, regenerating: %s", json_path.name, e)
except OSError as e:
logger.warning("[json_handler] Unreadable JSON file %s, regenerating: %s", json_path.name, e)
template = _default_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 = MAX_LOG_ENTRIES
if config and "config" in config:
max_entries = config["config"].get("max_log_entries", MAX_LOG_ENTRIES)
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: daemon/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 daemon branch.
Provides activity collection and memory health checking
for the Branch Activity Monitoring System (FPLAN-0266).
"""
from aipass.daemon.apps.handlers.monitoring.activity_collector import (
load_branch_registry,
get_branch_paths,
scan_branch_activity,
get_all_branch_activity,
)
from aipass.daemon.apps.handlers.monitoring.memory_health import (
check_memory_files_exist,
validate_memory_structure,
check_freshness,
get_memory_health_status,
)
from aipass.daemon.apps.handlers.monitoring.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,326 @@
# =================== AIPass ====================
# Name: activity_collector.py
# Description: Branch Activity Data Collector
# Version: 0.1.0
# Created: 2026-01-30
# Modified: 2026-01-30
# =============================================
"""
Branch Activity Data Collector Handler
Collects activity data from all branches in the AIPass system.
Scans for code files (.py) and memory files (.trinity/*.json, README.md, DASHBOARD.local.json).
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
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# Constants — find registry: env var > repo root > ~/.aipass/
_REPO_ROOT = Path(__file__).resolve().parents[6] # src/aipass/daemon/apps/handlers/monitoring -> repo root
_REGISTRY_CANDIDATES = [
Path(os.environ.get("AIPASS_REGISTRY", "")),
_REPO_ROOT / "AIPASS_REGISTRY.json",
Path.home() / ".aipass" / "AIPASS_REGISTRY.json",
]
REGISTRY_PATH = next((p for p in _REGISTRY_CANDIDATES if p.name and p.exists()), _REGISTRY_CANDIDATES[-1])
MEMORY_FILE_PATTERNS = ["local.json", "observations.json", "passport.json", "README.md", "DASHBOARD.local.json"]
CODE_FILE_EXTENSION = ".py"
def load_branch_registry() -> Dict[str, Any]:
"""
Load the AIPASS_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) as e:
logger.warning("Failed to load AIPASS_REGISTRY %s: %s", REGISTRY_PATH, e)
return {"metadata": {}, "branches": []}
def get_branch_paths() -> List[Dict[str, str]]:
"""
Get all branch names and absolute paths from the registry.
Registry stores relative paths (e.g. 'src/aipass/daemon').
This resolves them against the repo root so consumers get absolute paths.
Returns:
List of dicts with 'name' and 'path' keys for each branch.
"""
registry = load_branch_registry()
branches = registry.get("branches", [])
result = []
for b in branches:
name = b.get("name", "")
raw_path = b.get("path", "")
if not name or not raw_path:
continue
# Resolve relative registry paths against repo root
resolved = Path(raw_path)
if not resolved.is_absolute():
resolved = _REPO_ROOT / raw_path
result.append({"name": name, "path": str(resolved)})
return result
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 as e:
logger.warning("Failed to get mtime for %s: %s", file_path, e)
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:
- .trinity/local.json
- .trinity/observations.json
- .trinity/passport.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
parent_name = file_path.parent.name
# Check for .trinity/ memory files
if parent_name == ".trinity" and name in ("local.json", "observations.json", "passport.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 _should_skip_dir(item: Path) -> bool:
"""Check if a directory should be skipped during scanning."""
if item.name == "__pycache__":
return True
return item.name.startswith(".") and item.name != ".trinity"
def _process_file(item: Path) -> None:
"""Categorize a single file into code_files or memory_files."""
mtime = _get_file_mtime(item)
if mtime is None:
return
if since and mtime < since:
return
file_info = {
"path": str(item),
"name": item.name,
"mtime": mtime.isoformat(),
"mtime_datetime": mtime,
}
if _is_memory_file(item, branch_name):
memory_files.append(file_info)
elif item.suffix == CODE_FILE_EXTENSION:
code_files.append(file_info)
def scan_recursive(path: Path, depth: int = 0) -> None:
"""Recursively collect code and memory files up to max_depth."""
if depth > max_depth:
return
try:
for item in path.iterdir():
if item.is_dir():
if not _should_skip_dir(item):
scan_recursive(item, depth + 1)
elif item.is_file():
_process_file(item)
except PermissionError as e:
logger.warning("Permission denied scanning %s: %s", path, e)
except OSError as e:
logger.warning("OS error scanning %s: %s", path, e)
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)
json_handler.log_operation("activity_scan")
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,408 @@
# =================== AIPass ====================
# Name: memory_health.py
# Description: Branch Memory Health Checker
# Version: 0.1.0
# Created: 2026-01-30
# Modified: 2026-01-30
# =============================================
"""
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
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# Health status constants
STATUS_OK = "OK"
STATUS_WARNING = "WARNING"
STATUS_RED = "RED"
# Required memory files (branch cannot function properly without these)
# These live inside the .trinity/ subdirectory of each branch
REQUIRED_FILES = [".trinity/local.json", "README.md"]
# Optional memory files (nice to have, warning if missing)
OPTIONAL_FILES = [".trinity/observations.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 (actual .trinity/ structure):
- .trinity/local.json
- README.md
Optional files:
- .trinity/observations.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)
trinity_dir = directory / ".trinity"
# Build expected file paths
required_checks = {
".trinity/local.json": trinity_dir / "local.json",
"README.md": directory / "README.md",
}
optional_checks = {
".trinity/observations.json": trinity_dir / "observations.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:
logger.warning("Invalid JSON in memory file %s: %s", file_path, e)
return {
"valid": False,
"has_metadata": False,
"has_limits": False,
"issues": [f"Invalid JSON: {str(e)}"],
"metadata_fields": [],
}
except OSError as e:
logger.warning("Cannot read memory file %s: %s", file_path, 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:
logger.error("Cannot read file stats for %s: %s", file_path, 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
}
"""
json_handler.log_operation("memory_health_check", {"branch": branch_name})
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 (.trinity/ paths)
structure_checks = {}
trinity_dir = directory / ".trinity"
local_file = trinity_dir / "local.json"
obs_file = trinity_dir / "observations.json"
if local_file.exists():
local_validation = validate_memory_structure(str(local_file))
structure_checks[".trinity/local.json"] = local_validation
if not local_validation["valid"]:
for issue in local_validation["issues"]:
issues.append(f".trinity/local.json: {issue}")
if obs_file.exists():
obs_validation = validate_memory_structure(str(obs_file))
structure_checks[".trinity/observations.json"] = obs_validation
if not obs_validation["valid"]:
for issue in obs_validation["issues"]:
issues.append(f".trinity/observations.json: {issue}")
# Step 3: Check freshness
freshness_checks = {}
files_to_check = [
(".trinity/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,354 @@
# =================== AIPass ====================
# Name: red_flag_detector.py
# Description: Branch Red Flag Detection Engine
# Version: 0.1.0
# Created: 2026-01-30
# Modified: 2026-01-30
# =============================================
"""
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 aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.monitoring 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) as e:
logger.warning("Failed to parse ISO datetime %s: %s", iso_string, e)
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:
logger.error("Failed to scan branch %s: %s", branch_name, 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)
json_handler.log_operation("red_flag_scan")
# 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:
"""Sort branches by status severity (RED_FLAG first), then alphabetically."""
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,489 @@
# =================== AIPass ====================
# Name: report_generator.py
# Description: Activity Report Generator Handler
# Version: 1.0.0
# Created: 2026-03-08
# Modified: 2026-03-08
# =============================================
"""
Activity Report Generator Handler
Generates formatted CLI-ready activity reports from monitoring data.
Produces human-readable string reports - no display dependencies.
"""
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from aipass.prax import logger
# logger imported from aipass.prax
from aipass.daemon.apps.handlers.json import json_handler
# Import sibling monitoring handlers
from aipass.daemon.apps.handlers.monitoring import activity_collector
from aipass.daemon.apps.handlers.monitoring import memory_health
from aipass.daemon.apps.handlers.monitoring import red_flag_detector
# =============================================
# CONSTANTS
# =============================================
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) as e:
logger.warning("Failed to parse timestamp %s: %s", iso_timestamp, e)
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 _format_branch_status_lines(
branch_status: Dict[str, Any],
health_data: Dict[str, Any],
verbosity: str,
lines: List[str],
) -> None:
"""Append formatted status lines for a single branch."""
name = branch_status.get("branch_name", "UNKNOWN")
status = branch_status.get("status", "UNKNOWN")
reason = branch_status.get("reason", "")
branch_health = health_data.get(name, {})
mem_status = branch_health.get("overall_status", "UNKNOWN")
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:
mem_update = branch_status.get("memory_last_update")
time_ago = _format_time_ago(mem_update)
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})")
if verbosity == "detailed" and status != "NO_ACTIVITY":
_format_branch_detail_lines(branch_status, lines)
def _format_branch_detail_lines(branch_status: Dict[str, Any], lines: List[str]) -> None:
"""Append detailed file-change lines for a single branch."""
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')}")
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.
"""
json_handler.log_operation("report_generated")
data = _aggregate_data(since_hours)
red_flags = data["red_flag_summary"]
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)
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":
for branch_status in all_branches:
_format_branch_status_lines(branch_status, health_data, verbosity, lines)
# 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,
}
@@ -0,0 +1,38 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: __init__.py - Schedule Handlers Package
# Date: 2026-02-04
# Version: 1.0.0
# Category: daemon/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 daemon's scheduled follow-ups system.
"""
from aipass.daemon.apps.handlers.schedule.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,588 @@
# =================== AIPass ====================
# Name: task_registry.py
# Description: DAEMON Scheduled Tasks Registry
# Version: 1.0.0
# Created: 2026-02-04
# Modified: 2026-02-04
# =============================================
"""
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
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # 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 ensure_lock_dir() -> Dict[str, Any]:
"""Ensure the daemon_json directory exists for lock files.
Returns:
Dict with 'path' (str) of the lock file directory.
"""
lock_dir = SCHEDULE_JSON_PATH.parent
lock_dir.mkdir(parents=True, exist_ok=True)
return {"path": str(lock_dir)}
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) as e:
logger.error("[task_registry] Failed to load schedule.json: %s", e)
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 as e:
logger.error("[task_registry] Failed to save schedule.json: %s", e)
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 16-character UUID for task ID."""
return uuid.uuid4().hex[:16]
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., "@devpulse")
message: Message to deliver when due
Returns:
Created task dictionary
Raises:
ValueError: If due_date format is invalid
"""
json_handler.log_operation("task_created")
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 _is_stale_dispatch(started: str, cutoff: datetime) -> bool:
"""Check if a dispatch_started timestamp is older than the cutoff."""
try:
start_time = datetime.fromisoformat(started)
return start_time < cutoff
except ValueError as e:
logger.warning("[task_registry] Invalid dispatch_started timestamp, resetting task: %s", e)
return True
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":
continue
started = task.get("dispatch_started")
if not started:
continue
if _is_stale_dispatch(started, cutoff):
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"]
# =============================================
# BATCH PROCESSING
# =============================================
def _safe_mark_pending(task_id: str) -> None:
"""Best-effort reset a task to pending, logging on failure."""
try:
mark_pending(task_id)
except Exception as pending_err:
logger.error("[task_registry] Failed to reset task %s to pending: %s", task_id[:8], pending_err)
def process_due_tasks_batch(
send_email_fn=None,
stale_max_age: int = 5,
) -> Dict[str, Any]:
"""
Process all due tasks: recover stale, dispatch emails, track results.
This is the implementation logic for batch task processing.
The module layer handles display; this handler returns raw data.
Args:
send_email_fn: Callable to send email (to_branch, subject, message, ...).
If None, email dispatch is skipped.
stale_max_age: Maximum minutes before a dispatching task is considered stale.
Returns:
Dict with keys: due, success, failed, recovered, errors (list of str),
processed_tasks (list of dicts with id, recipient, task, status).
"""
import time
results: Dict[str, Any] = {
"due": 0,
"success": 0,
"failed": 0,
"recovered": 0,
"errors": [],
"processed_tasks": [],
}
# Recover any stale dispatches
try:
recovered = recover_stale_dispatches(max_age_minutes=stale_max_age)
results["recovered"] = recovered
except Exception as e:
logger.warning("[task_registry] Stale dispatch recovery failed: %s", e)
results["errors"].append(f"Stale recovery: {e}")
# Get due tasks
try:
due_tasks = get_due_tasks()
except Exception as e:
logger.error("[task_registry] Failed to load due tasks: %s", e)
results["errors"].append(f"Load tasks: {e}")
return results
results["due"] = len(due_tasks)
if not due_tasks:
return results
for task in due_tasks:
task_id = task.get("id", "")
recipient = task.get("recipient", "")
task_desc = task.get("task", "")
message = task.get("message", "")
task_result = {
"id": task_id,
"recipient": recipient,
"task": task_desc,
"status": "pending",
}
# Mark as dispatching (prevents re-dispatch)
try:
mark_dispatching(task_id)
except Exception as e:
logger.error("[task_registry] Failed to mark task %s as dispatching: %s", task_id[:8], e)
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
results["failed"] += 1
task_result["status"] = "error"
task_result["error"] = str(e)
results["processed_tasks"].append(task_result)
continue
# Build email body
email_body = f"{task_desc}"
if message:
email_body += f"\n\nDetails:\n{message}"
# Send the email
if send_email_fn is None:
mark_pending(task_id)
results["failed"] += 1
task_result["status"] = "skipped"
task_result["error"] = "email function not available"
results["errors"].append(f"Email unavailable for {task_id[:8]}")
results["processed_tasks"].append(task_result)
continue
try:
email_sent = send_email_fn(
to_branch=recipient,
subject=f"[SCHEDULED] {task_desc}",
message=email_body,
from_branch="@daemon",
auto_execute=True,
reply_to="@devpulse",
)
if email_sent:
mark_completed(task_id)
results["success"] += 1
task_result["status"] = "sent"
else:
mark_pending(task_id)
results["failed"] += 1
task_result["status"] = "failed"
task_result["error"] = "email send returned False"
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
except Exception as e:
logger.error("[task_registry] Email dispatch error for task %s: %s", task_id[:8], e)
_safe_mark_pending(task_id)
results["failed"] += 1
task_result["status"] = "error"
task_result["error"] = str(e)
results["errors"].append(f"Email error {task_id[:8]}: {e}")
results["processed_tasks"].append(task_result)
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
return results
# =============================================
# 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:
logger.warning("Date parse test failed for %s: %s", d, e)
console.print(f" {d} -> [red]ERROR: {e}[/red]")
# Test invalid date
try:
parse_due_date("invalid")
except ValueError as e:
logger.info("Expected parse failure for 'invalid': %s", 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="@devpulse",
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 @@
"""Telegram handlers - ARCHIVED. See .archive/ directory. Telegram moving to skills system."""
@@ -0,0 +1 @@
# Update handlers package
@@ -0,0 +1,108 @@
# =================== AIPass ====================
# Name: data_loader.py
# Description: DAEMON Data Loading Handler
# Version: 1.0.0
# Created: 2026-01-29
# Modified: 2026-01-29
# =============================================
"""
Handler for loading DAEMON data from inbox and local files.
"""
import json
from pathlib import Path
from typing import Dict, Any, List
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # 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."""
json_handler.log_operation("data_loaded")
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 as e:
logger.error("[data_loader] Failed to load inbox.json: %s", e)
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 as e:
logger.error("[data_loader] Failed to load DAEMON.local.json: %s", e)
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,9 @@
{
"module_name": "{{MODULE_NAME}}",
"version": "1.0.0",
"timestamp": "{{CURRENT_DATE}}",
"config": {
"auto_save": true,
"enabled": true
}
}
@@ -0,0 +1,8 @@
{
"module_name": "{{MODULE_NAME}}",
"created": "{{CURRENT_DATE}}",
"last_updated": "{{CURRENT_DATE}}",
"operations_total": 0,
"operations_successful": 0,
"operations_failed": 0
}
@@ -0,0 +1 @@
[]
+5
View File
@@ -0,0 +1,5 @@
# Modules
Business logic for `DAEMON`. One module per command.
Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here.
@@ -0,0 +1 @@
# Modules package - Branch-specific functionality modules
+560
View File
@@ -0,0 +1,560 @@
# =================== AIPass ====================
# Name: actions.py
# Description: Action Registry CLI Module
# Version: 1.0.0
# Created: 2026-03-02
# Modified: 2026-03-02
# =============================================
"""
CLI interface for the numbered action registry.
"""
# =============================================
# IMPORTS
# =============================================
import sys
from typing import List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error as cli_error
from aipass.daemon.apps.handlers.actions.actions_registry import (
list_actions,
get_action,
toggle_action,
delete_action,
create_action,
migrate_plugins,
next_due_str,
)
from aipass.daemon.apps.handlers.json import json_handler
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):
cli_error(text)
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "actions"
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]actions Module[/bold cyan]")
console.print()
console.print("[dim]CLI interface for the numbered action registry (DPLAN-043)[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/actions/")
console.print(
" [cyan]*[/cyan] actions_registry.py"
" [dim](list_actions, get_action,"
" toggle_action, delete_action, create_action,"
" migrate_plugins, next_due_str — registry CRUD)[/dim]"
)
console.print()
# =============================================
# OUTPUT FORMATTING
# =============================================
def _format_schedule(action: dict) -> str:
"""Build schedule display string for an action."""
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
return f"daily @ {action.get('time', '??:??')}"
if schedule_type == "hourly":
m = action.get("time", "0")
return f"hourly @ :{int(m):02d}"
if schedule_type == "interval":
mins = action.get("interval_minutes", 0)
if mins >= 60:
return f"every {mins // 60}h"
return f"every {mins}m"
if schedule_type == "once":
return f"once: {action.get('due_date', '?')}"
return schedule_type
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} {'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_str = _format_schedule(action)
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 @seedgo "Run audit" daily 04:00')
console.print(' set schedule @daemon "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)
logger.info("[DAEMON] actions: Action list displayed")
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 True # Error displayed
toggle_action(action_id, enable)
state = "enabled" if enable else "disabled"
_success(f"Action {action_id} ({action['name']}) {state}")
logger.info("[DAEMON] actions: Action toggled")
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 True # Error displayed
_print_action_detail(action)
logger.info("[DAEMON] actions: Action info displayed")
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 True # Error displayed
date_str = args[0]
message = args[1]
target_branch = "@devpulse" # 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 True # Error displayed
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()
logger.info("[DAEMON] actions: Reminder set")
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 True # Error displayed
target_branch = args[0]
prompt = args[1]
schedule_type = args[2]
time_val = None
interval_minutes = None
if schedule_type not in ("daily", "hourly", "interval"):
_error(f"Unknown schedule type: {schedule_type}")
console.print("[dim]Valid types: daily, hourly, interval[/dim]")
return True # Error displayed
if len(args) < 4:
_error(f"{schedule_type.title()} schedule requires a time/value argument")
return True # Error displayed
if schedule_type in ("daily", "hourly"):
time_val = args[3]
else:
try:
interval_minutes = int(args[3])
except ValueError:
logger.warning("Invalid interval minutes value: %s", args[3])
_error(f"Invalid interval minutes: {args[3]}")
return True # Error displayed
# 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()
logger.info("[DAEMON] actions: Schedule set")
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)
logger.info("[DAEMON] actions: Plugin migration completed")
return True
def _handle_delete(args: List[str]) -> bool:
"""Handle 'actions delete <id>'."""
if not args:
_error("Action ID required: actions delete <id>")
return True # Error displayed
action_id = args[0]
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
delete_action(action_id)
_success(f"Deleted action {action_id}: {action['name']}")
logger.info("[DAEMON] actions: Action deleted")
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:
logger.warning("Invalid relative day format: %s", date_str)
return ""
elif date_str.endswith("w"):
try:
weeks = int(date_str[:-1])
return (datetime.now() + timedelta(weeks=weeks)).strftime("%Y-%m-%d")
except ValueError:
logger.warning("Invalid relative week format: %s", date_str)
return ""
# ISO date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
logger.warning("Invalid ISO date format: %s", date_str)
return ""
# =============================================
# ORCHESTRATION
# =============================================
def _route_set_subcommand(args: List[str]) -> bool:
"""Route 'actions set reminder ...' / 'actions set schedule ...'."""
if len(args) < 2:
_error("Usage: actions set <reminder|schedule> ...")
return True # Error displayed
set_type = args[1]
if set_type == "reminder":
return _handle_set_reminder(args[2:])
if set_type == "schedule":
return _handle_set_schedule(args[2:])
_error(f"Unknown set type: {set_type}. Use 'reminder' or 'schedule'.")
return True # Error displayed
def _route_action_id(action_id: str, args: List[str]) -> bool:
"""Route 'actions <4-digit-id> [on|off|info]'."""
if len(args) < 2:
return _handle_info(action_id)
sub_action = args[1]
if sub_action == "on":
return _handle_toggle(action_id, True)
if sub_action == "off":
return _handle_toggle(action_id, False)
if sub_action == "info":
return _handle_info(action_id)
_error(f"Unknown action command: {sub_action}. Use 'on', 'off', or 'info'.")
return True # Error displayed
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 -- introspection gate
if not args:
print_introspection()
return True
# Help flag
if args[0] in ["--help", "-h", "help"]:
print_help()
return True
subcommand = args[0]
json_handler.log_operation("actions_command", {"subcommand": args[0] if args else "introspection"})
# Named subcommands
if subcommand == "list":
return _handle_list(args[1:])
if subcommand == "migrate":
return _handle_migrate(args[1:])
if subcommand == "delete":
return _handle_delete(args[1:])
if subcommand == "set":
return _route_set_subcommand(args)
# Check if first arg is an action ID (4-digit numeric)
if subcommand.isdigit() and len(subcommand) == 4:
return _route_action_id(subcommand, args)
_error(f"Unknown subcommand: {subcommand}")
console.print("[dim]Run 'actions --help' for available commands[/dim]")
return True # Command was handled (error displayed)
except Exception as e:
logger.error("[actions] Error in actions command: %s", e, exc_info=True)
_error(f"Error: {e}")
return True # Error displayed
# =============================================
# 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,315 @@
# =================== AIPass ====================
# Name: activity_report.py
# Description: Branch Activity Report Generator Module
# Version: 0.2.0
# Created: 2026-01-30
# Modified: 2026-03-08
# =============================================
"""
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 typing import List
from aipass.prax import logger
# logger imported from aipass.prax
from aipass.cli.apps.modules import console, error
from aipass.daemon.apps.handlers.json import json_handler
# Import report generation handler (implementation lives in handler layer)
from aipass.daemon.apps.handlers.monitoring.report_generator import (
generate_activity_report,
generate_branch_report,
get_json_report,
)
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "activity_report"
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]activity_report Module[/bold cyan]")
console.print()
console.print(
"[dim]Branch activity report generator — monitors file changes, memory health, and presence violations[/dim]"
)
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/monitoring/")
console.print(
" [cyan]*[/cyan] report_generator.py"
" [dim](generate_activity_report,"
" generate_branch_report, get_json_report"
" — report generation and JSON output)[/dim]"
)
console.print()
# =============================================
# COMMAND INTEGRATION (AUTO-DISCOVERY)
# =============================================
def _print_activity_help() -> None:
"""Display help for the activity command."""
console.print()
console.print("=" * 60)
console.print("ACTIVITY - Quick Activity Summary")
console.print("=" * 60)
console.print()
console.print("USAGE:")
console.print(" drone @daemon activity")
console.print(" daemon activity")
console.print(" daemon activity --hours 48")
console.print()
console.print("DESCRIPTION:")
console.print(" Quick 24-hour activity summary (default).")
console.print(" Shows branch status, red flags, and recommendations.")
console.print()
console.print("OPTIONS:")
console.print(" --hours N, -t N Time window in hours (default: 24)")
console.print(" --help, -h Show this help message")
console.print()
def _print_activity_report_help() -> None:
"""Display help for the activity-report command."""
console.print()
console.print("=" * 60)
console.print("ACTIVITY-REPORT - Full Detailed Report")
console.print("=" * 60)
console.print()
console.print("USAGE:")
console.print(" drone @daemon activity-report")
console.print(" daemon activity-report")
console.print(" daemon activity-report --hours 48")
console.print(" daemon activity-report --json")
console.print()
console.print("DESCRIPTION:")
console.print(" Full detailed activity report with file-level changes.")
console.print(" Includes per-branch breakdown and complete recommendations.")
console.print()
console.print("OPTIONS:")
console.print(" --hours N, -t N Time window in hours (default: 24)")
console.print(" --json, -j Output raw JSON data")
console.print(" --help, -h Show this help message")
console.print()
def _print_branch_health_help() -> None:
"""Display help for the branch-health command."""
console.print()
console.print("=" * 60)
console.print("BRANCH-HEALTH - Single Branch Deep Dive")
console.print("=" * 60)
console.print()
console.print("USAGE:")
console.print(" drone @daemon branch-health DRONE")
console.print(" daemon branch-health FLOW")
console.print(" daemon branch-health SEEDGO --hours 48")
console.print()
console.print("DESCRIPTION:")
console.print(" Deep dive report for a single branch.")
console.print(" Shows all file changes, memory health, and specific recommendations.")
console.print()
console.print("OPTIONS:")
console.print(" <branch_name> Required - branch name (e.g., DRONE, FLOW, SEEDGO)")
console.print(" --hours N, -t N Time window in hours (default: 24)")
console.print(" --help, -h Show this help message")
console.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 as e:
logger.warning("Invalid --hours value '%s': %s", args[i + 1], e)
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_report' as alias — help shows module name, users expect it to work
if command == "activity_report":
if args and args[0] in ("--help", "-h", "help"):
print_introspection()
return True
json_handler.log_operation("activity_report", {"command": command})
hours = _parse_hours_arg(args)
report = generate_activity_report(since_hours=hours, verbosity="normal")
console.print(report)
logger.info("[DAEMON] activity_report: Activity summary generated")
return True
# Handle 'activity' command - quick summary (runs with no args, defaults to 24h)
if command == "activity":
if args and args[0] in ("--help", "-h", "help"):
_print_activity_help()
return True
json_handler.log_operation("activity_report", {"command": command})
hours = _parse_hours_arg(args)
report = generate_activity_report(since_hours=hours, verbosity="normal")
console.print(report)
logger.info("[DAEMON] activity_report: Activity summary generated")
return True
# Handle 'activity-report' command - detailed report (runs with no args, defaults to 24h)
if command == "activity-report":
if args and args[0] in ("--help", "-h", "help"):
_print_activity_report_help()
return True
json_handler.log_operation("activity_report", {"command": command})
hours = _parse_hours_arg(args)
# Check for --json flag
if "--json" in args or "-j" in args:
import json
data = get_json_report(hours)
console.print(json.dumps(data, indent=2))
else:
report = generate_activity_report(since_hours=hours, verbosity="detailed")
console.print(report)
logger.info("[DAEMON] activity_report: Detailed report generated")
return True
# Handle 'branch-health' command - requires branch name arg
if command == "branch-health":
return _handle_branch_health(args)
# Not our command
return False
def _extract_branch_name(args: List[str]) -> str | None:
"""Extract the first non-flag argument as the branch name."""
i = 0
while i < len(args):
if args[i] in ("--hours", "-t") and i + 1 < len(args):
i += 2
elif args[i].startswith("-"):
i += 1
else:
return args[i]
return None
def _handle_branch_health(args: List[str]) -> bool:
"""Handle 'branch-health [branch]' command. No args = all branches summary."""
if not args:
json_handler.log_operation("branch_health_all", {"command": "branch-health"})
report = generate_activity_report(since_hours=24, verbosity="normal")
console.print(report)
return True
if args[0] in ("--help", "-h", "help"):
_print_branch_health_help()
return True
branch_name = _extract_branch_name(args)
if not branch_name:
error("branch-health requires a branch name")
console.print()
console.print("Usage: branch-health <branch_name> [--hours N]")
console.print("Example: branch-health DRONE")
return True
hours = _parse_hours_arg(args)
report = generate_branch_report(branch_name, since_hours=hours)
console.print(report)
logger.info("[DAEMON] activity_report: Branch health report generated for %s", branch_name)
return True
# =============================================
# 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)
console.print(json.dumps(data, indent=2))
elif args.branch:
report = generate_branch_report(args.branch, args.hours)
console.print(report)
else:
report = generate_activity_report(args.hours, args.verbosity)
console.print(report)
if __name__ == "__main__":
main()
+436
View File
@@ -0,0 +1,436 @@
# =================== AIPass ====================
# Name: schedule.py
# Description: DAEMON Scheduled Follow-ups Module
# Version: 1.0.0
# Created: 2026-02-04
# Modified: 2026-02-04
# =============================================
"""
CLI interface for fire-and-forget scheduled follow-ups.
"""
# =============================================
# IMPORTS
# =============================================
import sys
import argparse
import subprocess
from pathlib import Path
from typing import List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error as cli_error
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.schedule.task_registry import (
load_tasks,
create_task,
delete_task,
parse_due_date,
process_due_tasks_batch,
ensure_lock_dir,
)
# File lock for single-instance execution
try:
from filelock import FileLock, Timeout
FILELOCK_AVAILABLE = True
except ImportError:
FILELOCK_AVAILABLE = False
FileLock = None # type: ignore[assignment,misc]
Timeout = None # type: ignore[assignment,misc]
logger.info("Optional: filelock not available")
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):
cli_error(text)
def _send_email_via_drone(
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
):
"""Send email via drone @ai_mail send subprocess."""
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
if auto_execute:
cmd.append("--dispatch")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=DRONE_SUBPROCESS_TIMEOUT)
return result.returncode == 0
except (subprocess.SubprocessError, OSError) as e:
logger.warning("Drone email subprocess failed: %s", e)
return False
AI_MAIL_AVAILABLE = True
send_email_direct = _send_email_via_drone
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "schedule"
# Constants
DRONE_SUBPROCESS_TIMEOUT = 15 # seconds
STALE_DISPATCH_MAX_AGE = 5 # minutes
LOCK_ACQUIRE_TIMEOUT = 0 # seconds (non-blocking)
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]schedule Module[/bold cyan]")
console.print()
console.print("[dim]CLI interface for fire-and-forget scheduled follow-ups[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/schedule/")
console.print(
" [cyan]*[/cyan] task_registry.py"
" [dim](load_tasks, create_task, delete_task,"
" get_due_tasks, mark_completed, parse_due_date,"
" mark_dispatching, mark_pending, recover_stale_dispatches,"
" process_due_tasks_batch, ensure_lock_dir"
" — task CRUD and processing)[/dim]"
)
console.print()
_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, @seedgo)")
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 Seedgo about code review in 2 weeks")
console.print(' schedule create "Code review follow-up" --due 2w --to @seedgo --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:
logger.warning("Invalid arguments for schedule create")
_error('Usage: schedule create "task" --due <date> --to @branch [--message "details"]')
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)
logger.info("[DAEMON] schedule: Task list displayed")
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"
ensure_lock_dir()
# Try to acquire lock (non-blocking)
# FILELOCK_AVAILABLE guard above ensures these are not None
lock = FileLock(lock_file, timeout=LOCK_ACQUIRE_TIMEOUT) # type: ignore[misc]
try:
with lock.acquire(timeout=LOCK_ACQUIRE_TIMEOUT):
return _process_due_tasks()
except Timeout: # type: ignore[misc]
logger.warning("Schedule run-due already in progress, skipping")
console.print("[dim]Schedule run-due already in progress, skipping.[/dim]")
return True
def _display_task_result(task_result: dict) -> None:
"""Display a single processed task result."""
task_id = task_result.get("id", "")[:8]
recipient = task_result.get("recipient", "")
task_desc = task_result.get("task", "")[:40]
status = task_result.get("status", "")
if status == "sent":
_success(f"Sent to {recipient}: {task_desc}")
logger.info(f"[DAEMON] Scheduled email sent: {task_id} -> {recipient}")
elif status == "skipped":
_error(f"ai_mail not available, cannot send to {recipient}")
elif status == "failed":
_error(f"Failed to send to {recipient}: {task_desc}")
logger.error(f"[DAEMON] Scheduled email failed: {task_id} -> {recipient}")
elif status == "error":
_error(f"Error sending to {recipient}: {task_result.get('error', '')}")
logger.error(f"[DAEMON] Scheduled email error: {task_id} -> {recipient}: {task_result.get('error', '')}")
def _process_due_tasks() -> bool:
"""Process due tasks -- delegates to handler, formats output."""
try:
# Delegate to handler for all implementation logic
email_fn = send_email_direct if AI_MAIL_AVAILABLE else None
results = process_due_tasks_batch(send_email_fn=email_fn, stale_max_age=STALE_DISPATCH_MAX_AGE)
# Display results (module responsibility)
if results["recovered"]:
console.print(f"[dim]Recovered {results['recovered']} stale dispatch(es)[/dim]")
if results["due"] == 0:
console.print("[dim]No tasks due at this time.[/dim]")
return True
console.print()
_header(f"Running {results['due']} Due Task(s)")
console.print()
for task_result in results.get("processed_tasks", []):
_display_task_result(task_result)
console.print()
console.print(f"[bold]Results:[/bold] {results['success']} sent, {results['failed']} failed")
console.print()
if results["failed"] > 0:
logger.warning("[DAEMON] %d scheduled task(s) failed to send", results["failed"])
else:
logger.info("[DAEMON] schedule: Processed due tasks")
return True # Command was handled (failures are logged, not routing errors)
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:
# No args -- introspection gate
if not args:
print_introspection()
return True
# Handle help flag
if args[0] in ["--help", "-h", "help"]:
_print_help()
return True
subcommand = args[0]
subargs = args[1:]
json_handler.log_operation("schedule_command", {"subcommand": args[0] if args else "list"})
# Route to subcommand handlers
if subcommand == "create":
return _handle_create(subargs)
if subcommand == "list":
return _handle_list(subargs)
if subcommand == "delete":
return _handle_delete(subargs)
if subcommand == "run-due":
return _handle_run_due(subargs)
_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()
@@ -0,0 +1,133 @@
# =================== AIPass ====================
# Name: scheduler_ops.py
# Description: Scheduler Cron Operations Module
# Version: 2.0.0
# Created: 2026-03-08
# Modified: 2026-03-10
# =============================================
"""
Scheduler operations module -- facade for cron entry point.
Provides a clean module-layer interface over handler functions
used by scheduler_cron.py.
"""
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
try:
from aipass.cli.apps.modules.display import console
except ImportError:
from rich.console import Console
console = Console()
logger.info("Optional: aipass.cli.apps.modules.display not available, using rich.console fallback")
# =============================================
# TASK REGISTRY
# =============================================
try:
from aipass.daemon.apps.handlers.schedule.task_registry import (
get_due_tasks as get_due_tasks,
mark_dispatching as mark_dispatching,
mark_completed as mark_completed,
mark_pending as mark_pending,
recover_stale_dispatches as recover_stale_dispatches,
)
TASK_REGISTRY_AVAILABLE = True
except ImportError:
TASK_REGISTRY_AVAILABLE = False
get_due_tasks = None # type: ignore[assignment]
mark_dispatching = None # type: ignore[assignment]
mark_completed = None # type: ignore[assignment]
mark_pending = None # type: ignore[assignment]
recover_stale_dispatches = None # type: ignore[assignment]
logger.info("Optional: task_registry not available")
# =============================================
# ACTION REGISTRY (DPLAN-043)
# =============================================
try:
from aipass.daemon.apps.handlers.actions.actions_registry import (
load_registry as load_registry,
is_action_due as is_action_due,
update_last_run as update_last_run,
mark_reminder_completed as mark_reminder_completed,
migrate_plugins as migrate_plugins,
next_due_str as next_due_str,
)
ACTION_REGISTRY_AVAILABLE = True
except ImportError:
ACTION_REGISTRY_AVAILABLE = False
load_registry = None # type: ignore[assignment]
is_action_due = None # type: ignore[assignment]
update_last_run = None # type: ignore[assignment]
mark_reminder_completed = None # type: ignore[assignment]
migrate_plugins = None # type: ignore[assignment]
next_due_str = None # type: ignore[assignment]
logger.info("Optional: actions_registry not available")
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]scheduler_ops Module[/bold cyan]")
console.print()
console.print("[dim]Facade for scheduler_cron.py — re-exports handler functions for cron entry point[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/schedule/")
console.print(
" [cyan]*[/cyan] task_registry.py"
" [dim](get_due_tasks, mark_dispatching,"
" mark_completed, mark_pending,"
" recover_stale_dispatches — task lifecycle)[/dim]"
)
console.print()
console.print(" handlers/actions/")
console.print(
" [cyan]*[/cyan] actions_registry.py"
" [dim](load_registry, is_action_due,"
" update_last_run, mark_reminder_completed,"
" migrate_plugins, next_due_str — action registry)[/dim]"
)
console.print()
# =============================================
# DRONE ROUTING
# =============================================
def handle_command(command: str, args: list) -> bool:
"""Handle commands routed by the entry point."""
if command == "scheduler-ops":
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
json_handler.log_operation("scheduler_ops_status")
console.print()
console.print("[bold cyan]Scheduler Ops[/bold cyan] - Cron operations facade")
console.print()
console.print(" [dim]Notifications:[/dim] archived (Telegram removed)")
console.print(f" [dim]Task registry:[/dim] {TASK_REGISTRY_AVAILABLE}")
console.print(f" [dim]Action registry:[/dim] {ACTION_REGISTRY_AVAILABLE}")
console.print()
console.print("[dim]This module is a facade used by scheduler_cron.py.[/dim]")
console.print()
return True
return False
+200
View File
@@ -0,0 +1,200 @@
# =================== AIPass ====================
# Name: update.py
# Description: DAEMON Status Digest Module
# Version: 1.0.0
# Created: 2026-01-29
# Modified: 2026-01-29
# =============================================
"""
Returns digest of DAEMON activity for check-ins.
"""
# =============================================
# IMPORTS
# =============================================
import sys
from typing import Dict, Any, List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.update.data_loader import (
load_inbox,
load_local,
categorize_messages,
get_session_summary,
get_escalations,
)
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]")
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "update"
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]update Module[/bold cyan]")
console.print()
console.print("[dim]Returns digest of DAEMON activity for check-ins[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/update/")
console.print(
" [cyan]*[/cyan] data_loader.py"
" [dim](load_inbox, load_local,"
" categorize_messages, get_session_summary,"
" get_escalations — inbox and session data)[/dim]"
)
console.print()
# =============================================
# 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
# No args = run the digest (this is the primary use case)
json_handler.log_operation("update_digest")
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)
error(f"Error: {e}")
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,68 @@
# =================== AIPass ====================
# Name: wakeup_ops.py
# Description: Wake-Up Cron Operations Module
# Version: 2.0.0
# Created: 2026-03-08
# Modified: 2026-03-10
# =============================================
"""
Wake-up operations module -- facade for cron entry point.
Provides a clean module-layer interface over handler functions
used by daemon_wakeup.py.
"""
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
try:
from aipass.cli.apps.modules.display import console
except ImportError:
from rich.console import Console
console = Console()
logger.info("Optional: aipass.cli.apps.modules.display not available, using rich.console fallback")
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]wakeup_ops Module[/bold cyan]")
console.print()
console.print("[dim]Facade for daemon_wakeup.py — notifications archived[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [dim](notifications archived — Telegram moving to skills system)[/dim]")
console.print()
# =============================================
# DRONE ROUTING
# =============================================
def handle_command(command: str, args: list) -> bool: # noqa: ARG001
"""Handle commands routed by the entry point."""
if command == "wakeup-ops":
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
json_handler.log_operation("wakeup_ops_status")
console.print()
console.print("[bold cyan]Wakeup Ops[/bold cyan] - Cron wake-up facade")
console.print()
console.print(" [dim]Notifications:[/dim] archived (Telegram moving to skills system)")
console.print()
console.print("[dim]This module is a facade used by daemon_wakeup.py.[/dim]")
console.print()
return True
return False
+5
View File
@@ -0,0 +1,5 @@
# Plugins
Scheduled tasks and extensions for `DAEMON`.
Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task.
@@ -0,0 +1,98 @@
# ===================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., "@seedgo")
"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
from aipass.prax import logger
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 as e:
logger.warning("Failed to load plugin %s: %s", module_name, e)
continue
return plugins
@@ -0,0 +1,447 @@
# =================== AIPass ====================
# Name: community_rotation.py
# Description: Rotating Community Engagement Plugin
# Version: 1.1.0
# Created: 2026-02-21
# Modified: 2026-02-22
# =============================================
"""
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 AIPASS_REGISTRY,
excluding VERA (has her own heartbeat), DEVPULSE (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
from aipass.prax import logger
# logger imported from aipass.prax
# 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 = {
"DEVPULSE", # Orchestrator branch -- should not self-wake
"DAEMON", # Self -- should not wake itself
}
# 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 AIPASS_REGISTRY."""
if not REGISTRY_PATH.exists():
logger.error("[community_rotation] AIPASS_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) as e:
logger.warning("[community_rotation] Failed to load rotation state: %s", e)
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:
logger.error("[community_rotation] Wake script timed out for %s", target["name"])
return {
"status": "failed",
"branch": target["email"],
"error": "wake script timed out (30s)",
}
except Exception as e:
logger.error("[community_rotation] Wake script failed for %s: %s", target["name"], e)
return {
"status": "failed",
"branch": target["email"],
"error": str(e),
}
@@ -0,0 +1,50 @@
# =================== AIPass ====================
# Name: daily_audit.py
# Description: Daily Standards Audit Plugin
# Version: 1.0.0
# Created: 2026-02-20
# Modified: 2026-02-20
# =============================================
"""
Daily Standards Audit Plugin
Wakes @seedgo daily at 04:00 with fresh context to run a full system audit.
Seedgo checks AIPASS_REGISTRY completeness, runs drone @seedgo audit @all,
fixes non-compliance issues, and emails a summary to @devpulse.
"""
from aipass.prax import logger
PLUGIN_CONFIG = {
"name": "daily_audit",
"schedule": "daily",
"time": "04:00",
"interval_minutes": None,
"enabled": True,
"branch": "@seedgo",
"fresh": True,
"max_turns": 50,
"prompt": (
"Daily maintenance audit. "
"1) Read AIPASS_REGISTRY.json - confirm all branches are registered and paths exist. "
"2) Run drone @seedgo audit @all - check standards compliance across all branches. "
"3) Fix any non-compliance issues you can fix directly. "
"4) Email summary to @devpulse 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.
"""
logger.info("[DAEMON] daily_audit: Plugin ready for dispatch")
return {
"status": "ready",
"plugin": PLUGIN_CONFIG["name"],
"branch": PLUGIN_CONFIG["branch"],
}
@@ -0,0 +1,62 @@
# =================== AIPass ====================
# Name: heartbeat.py
# Description: Periodic Heartbeat Plugin
# Version: 1.0.0
# Created: 2026-02-20
# Modified: 2026-02-23
# =============================================
"""
Periodic Heartbeat Plugin (v2.0 -- DPLAN-029)
Wakes target branch periodically for liveness check. The target's system
prompt + id.json (injected every turn) already contain its full identity,
role, teams, 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
"""
from aipass.prax import logger
PLUGIN_CONFIG = {
"name": "heartbeat",
"schedule": "interval",
"time": None,
"interval_minutes": 240, # Every 4 hours (was 30min -- too fast)
"enabled": False, # Disabled: configure target branch before enabling
"branch": "@daemon",
"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.
"""
logger.info("[DAEMON] heartbeat: Plugin ready for dispatch")
return {
"status": "ready",
"plugin": PLUGIN_CONFIG["name"],
"branch": PLUGIN_CONFIG["branch"],
}
+417
View File
@@ -0,0 +1,417 @@
# =================== AIPass ====================
# Name: scheduler_cron.py
# Description: DAEMON Scheduler Cron Trigger
# Version: 2.0.0
# Created: 2026-02-15
# Modified: 2026-03-24
# =============================================
"""
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. Recover stale dispatches
3. Process all due tasks (send emails, mark complete)
4. Process actions from registry
5. Log summary
"""
# =============================================
# IMPORTS
# =============================================
import sys
import time
import subprocess
from pathlib import Path
from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.actions.action_processor import process_actions
try:
import fcntl
except ImportError:
fcntl = None # type: ignore[assignment]
logger.info("[DAEMON] scheduler_cron: fcntl unavailable (Windows)")
# =============================================
# OPTIONAL IMPORTS (via module layer)
# =============================================
# Task registry (via module layer)
try:
from aipass.daemon.apps.modules.scheduler_ops import (
get_due_tasks,
mark_dispatching,
mark_completed,
mark_pending,
recover_stale_dispatches,
TASK_REGISTRY_AVAILABLE,
)
except ImportError as e:
logger.info(f"Optional dependency not available: scheduler_ops task registry ({e})")
TASK_REGISTRY_AVAILABLE = False
get_due_tasks = None
mark_dispatching = None
mark_completed = None
mark_pending = None
recover_stale_dispatches = None
# Email integration via drone subprocess
def _send_email_via_drone(
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
):
"""Send email via drone @ai_mail send subprocess."""
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
if auto_execute:
cmd.append("--dispatch")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return result.returncode == 0
except (subprocess.SubprocessError, OSError) as e:
logger.warning(f"Drone email subprocess failed: {e}")
return False
AI_MAIL_AVAILABLE = True
send_email_direct = _send_email_via_drone
# =============================================
# 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
# =============================================
# LOGGING
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]scheduler_cron Module[/bold cyan]")
console.print()
console.print("[dim]Cron trigger for scheduled tasks and action registry processing[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" modules/")
console.print(
" [cyan]*[/cyan] scheduler_ops.py [dim](task registry ops + action registry ops, notifications archived)[/dim]"
)
console.print()
console.print(" plugins/")
console.print(" [cyan]*[/cyan] discover_plugins [dim](plugin discovery and scheduled execution)[/dim]")
console.print()
def print_help() -> None:
"""Display usage information for scheduler_cron."""
console.print("\n[bold cyan]scheduler_cron.py - DAEMON Scheduler Cron Trigger[/bold cyan]")
console.print("\n[yellow]USAGE:[/yellow]")
console.print(" drone @daemon scheduler_cron Run the cron scheduler")
console.print(" drone @daemon scheduler_cron --help Show this help message")
console.print("\n[yellow]DESCRIPTION:[/yellow]")
console.print(" Processes due scheduled tasks and actions from the registry.")
console.print(" Intended to be called periodically by cron.")
console.print()
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")
console.print(f"[{timestamp}] {message}")
# =============================================
# 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) # type: ignore[misc]
results["recovered"] = recovered
if recovered:
log(f"Recovered {recovered} stale dispatch(es)")
except Exception as e:
logger.warning(f"Failed to recover stale dispatches: {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() # type: ignore[misc]
except Exception as e:
logger.error(f"Failed to load due tasks: {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:
_process_single_task(task, results)
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
return results
def _process_single_task(task: dict, results: dict) -> None:
"""Process a single due task: mark dispatching, send email, update results."""
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) # type: ignore[misc]
except Exception as e:
logger.warning(f"Failed to mark dispatching {task_id[:8]}: {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
return
# 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) # type: ignore[misc]
results["failed"] += 1
results["errors"].append(f"ai_mail unavailable for {task_id[:8]}")
return
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="@devpulse",
)
if email_sent:
mark_completed(task_id) # type: ignore[misc]
log(f"OK: Sent to {recipient}: {task_desc[:40]}")
results["success"] += 1
else:
mark_pending(task_id) # type: ignore[misc]
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) # type: ignore[misc]
except Exception as reset_err:
logger.warning(f"Best-effort reset to pending failed for {task_id[:8]}: {reset_err}")
logger.error(f"Exception sending to {recipient}: {e}")
log(f"ERROR: Exception sending to {recipient}: {e}")
results["failed"] += 1
results["errors"].append(f"Email error {task_id[:8]}: {e}")
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}"
# =============================================
# MAIN
# =============================================
def main() -> int:
"""
Main cron entry point.
Returns:
0 on success, 1 on error
"""
args = sys.argv[1:]
if not args:
print_introspection()
return 0
if args[0] in ["--version", "-V"]:
console.print("scheduler_cron v2.0.0")
return 0
if args[0] in ["--help", "-h"]:
print_help()
sys.exit(0)
json_handler.log_operation("cron_run")
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)
if fcntl is None:
log("fcntl not available (non-Unix platform), skipping lock.")
return _run_locked()
lock_fd = open(LOCK_FILE, "w", encoding="utf-8") # noqa: SIM115
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
logger.warning(f"Scheduler lock acquisition failed (another instance running): {e}")
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: Process due tasks
try:
results = process_due_tasks()
except Exception as e:
logger.error(f"Unhandled error in process_due_tasks: {e}", exc_info=True)
log(f"CRITICAL: Unhandled error in process_due_tasks: {e}")
return 1
# Step 2: Process actions from registry
action_results = {
"total": 0,
"enabled": 0,
"executed": 0,
"failed": 0,
"errors": [],
"executed_actions": [],
"skipped_actions": [],
}
try:
action_results = process_actions(log_fn=log, send_email_fn=send_email_direct)
except Exception as e:
logger.warning(f"Unhandled error in process_actions: {e}")
log(f"WARNING: Unhandled error in process_actions: {e}")
action_results["errors"].append(f"Action processing: {e}")
# Step 3: Build 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: Determine exit code
if results["failed"] > 0 or results["errors"] or action_results["failed"] > 0 or action_results["errors"]:
exit_code = 1
log("Scheduler cron finished")
if exit_code == 0:
logger.info("[DAEMON] scheduler_cron: Cron cycle completed successfully")
log("=" * 60)
return exit_code
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
# Last-resort catch -- never crash silently
logger.error(f"FATAL scheduler_cron exception: {e}", exc_info=True)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[{timestamp}] FATAL: Unhandled exception: {e}")
sys.exit(1)
+3
View File
@@ -0,0 +1,3 @@
# Docs
Documentation files for the `DAEMON` branch.
+12
View File
@@ -0,0 +1,12 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
python_classes = Test*
addopts = -v --tb=short --strict-markers -ra
markers =
unit: Unit tests
integration: Integration tests
slow: Tests that take significant time
+5
View File
@@ -0,0 +1,5 @@
# Templates
Branch-specific templates for `DAEMON`.
Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo).
+6
View File
@@ -0,0 +1,6 @@
# Tests
Pytest unit tests for `DAEMON`.
- `conftest.py` — Shared fixtures (temp dirs, mocks, sample data).
- `test_*.py` — Test files. Standard tests cover JSON handler, CLI routing, and error resilience. Custom tests cover branch-specific domain logic.
+1
View File
@@ -0,0 +1 @@
# Tests package for daemon
+65
View File
@@ -0,0 +1,65 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: tests/conftest.py
# Date: 2025-11-08
# Version: 1.0.0
# Category: daemon/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/)
# =============================================
#
#@Meta header not seedgo standards
"""Shared pytest fixtures for daemon tests"""
import os
import tempfile
# Redirect prax logs to temp directory during tests
# Must be set before any prax imports to catch logger initialization
if "AIPASS_TEST_LOG_DIR" not in os.environ:
os.environ["AIPASS_TEST_LOG_DIR"] = tempfile.mkdtemp(prefix="aipass_test_logs_")
import pytest
import shutil
from pathlib import Path
from typing import Generator
from unittest.mock import MagicMock
@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"
}
@pytest.fixture()
def mock_json_handler() -> MagicMock:
"""Standalone mock json_handler for isolation tests."""
handler = MagicMock()
handler.load_json = MagicMock(return_value={})
handler.save_json = MagicMock(return_value=True)
handler.ensure_json_exists = MagicMock(return_value=True)
handler.ensure_module_jsons = MagicMock(return_value=True)
handler.get_json_path = MagicMock(return_value=Path("/tmp/mock.json"))
handler.validate_json_structure = MagicMock(return_value=True)
handler.log_operation = MagicMock(return_value=True)
return handler
@@ -0,0 +1,456 @@
# =================== AIPass ====================
# Name: test_actions_module.py
# Description: Tests for the actions CLI module
# Version: 1.0.0
# Created: 2026-04-02
# Modified: 2026-04-02
# =============================================
"""Tests for the actions CLI module (apps/modules/actions.py)."""
from datetime import datetime, timedelta
from unittest.mock import patch
MODULE = "aipass.daemon.apps.modules.actions"
# =============================================
# FIXTURES
# =============================================
def _make_action(
action_id: str = "0001",
name: str = "test_action",
enabled: bool = True,
schedule_type: str = "daily",
time: str = "08:00",
action_type: str = "schedule",
target_branch: str = "@seedgo",
interval_minutes: int | None = None,
due_date: str | None = None,
prompt: str = "Run tests",
) -> dict:
"""Build a sample action dict for tests."""
action: dict = {
"id": action_id,
"name": name,
"enabled": enabled,
"schedule_type": schedule_type,
"time": time,
"type": action_type,
"target_branch": target_branch,
"prompt": prompt,
"created": "2026-03-01T00:00:00",
"last_run": None,
}
if interval_minutes is not None:
action["interval_minutes"] = interval_minutes
if due_date is not None:
action["due_date"] = due_date
return action
# =============================================
# handle_command — routing
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleCommand:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("not_actions", []) is False
def test_no_args_shows_introspection(self, _err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
result = handle_command("actions", [])
assert result is True
# introspection prints "actions Module"
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("actions Module" in c for c in calls)
def test_help_flag(self, _err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["--help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("USAGE" in c for c in calls)
def test_help_word(self, _err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["help"]) is True
@patch(f"{MODULE}.list_actions", return_value=[])
@patch(f"{MODULE}.next_due_str", return_value="--")
def test_list_subcommand(self, _nds, _la, _err, mock_console, mock_jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["list"]) is True
mock_jh.log_operation.assert_called_once()
@patch(f"{MODULE}.migrate_plugins", return_value=2)
@patch(f"{MODULE}.list_actions", return_value=[])
@patch(f"{MODULE}.next_due_str", return_value="--")
def test_migrate_subcommand(self, _nds, _la, mock_migrate, _err, _con, mock_jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["migrate"]) is True
mock_migrate.assert_called_once()
@patch(f"{MODULE}.get_action")
@patch(f"{MODULE}.delete_action")
def test_delete_with_valid_id(self, mock_del, mock_get, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_get.return_value = _make_action()
assert handle_command("actions", ["delete", "0001"]) is True
mock_del.assert_called_once_with("0001")
def test_delete_missing_id(self, mock_err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["delete"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.create_action")
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
def test_set_reminder_valid(self, _pd, mock_create, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_create.return_value = _make_action(action_id="0099")
assert handle_command("actions", ["set", "reminder", "7d", "Check PR"]) is True
mock_create.assert_called_once()
@patch(f"{MODULE}.create_action")
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
def test_set_schedule_valid(self, _pd, mock_create, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_create.return_value = _make_action(action_id="0088")
assert handle_command("actions", ["set", "schedule", "@seedgo", "Run audit", "daily", "04:00"]) is True
mock_create.assert_called_once()
@patch(f"{MODULE}.get_action")
@patch(f"{MODULE}.next_due_str", return_value="--")
def test_action_id_routes(self, _nds, mock_get, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_get.return_value = _make_action(action_id="0003")
assert handle_command("actions", ["0003", "info"]) is True
mock_get.assert_called_with("0003")
def test_unknown_subcommand(self, mock_err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["foobar"]) is True
mock_err.assert_called()
# =============================================
# _handle_toggle
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleToggle:
@patch(f"{MODULE}.toggle_action")
@patch(f"{MODULE}.get_action")
def test_enable_success(self, mock_get, mock_toggle, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_toggle
mock_get.return_value = _make_action()
assert _handle_toggle("0001", True) is True
mock_toggle.assert_called_once_with("0001", True)
@patch(f"{MODULE}.toggle_action")
@patch(f"{MODULE}.get_action")
def test_disable_success(self, mock_get, mock_toggle, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_toggle
mock_get.return_value = _make_action()
assert _handle_toggle("0001", False) is True
mock_toggle.assert_called_once_with("0001", False)
@patch(f"{MODULE}.get_action", return_value=None)
def test_not_found(self, _get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_toggle
assert _handle_toggle("9999", True) is True
mock_err.assert_called()
# =============================================
# _handle_info
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleInfo:
@patch(f"{MODULE}.next_due_str", return_value="--")
@patch(f"{MODULE}.get_action")
def test_info_success(self, mock_get, _nds, _err, mock_console):
from aipass.daemon.apps.modules.actions import _handle_info
mock_get.return_value = _make_action()
assert _handle_info("0001") is True
# Should print detail header containing the action name
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("test_action" in c for c in calls)
@patch(f"{MODULE}.get_action", return_value=None)
def test_info_not_found(self, _get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_info
assert _handle_info("9999") is True
mock_err.assert_called()
# =============================================
# _handle_set_reminder
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleSetReminder:
def test_missing_args(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_reminder
assert _handle_set_reminder(["7d"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.create_action")
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
def test_with_to_flag(self, _pd, mock_create, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_reminder
mock_create.return_value = _make_action(action_id="0050")
assert _handle_set_reminder(["7d", "Follow up", "--to", "@flow"]) is True
call_kwargs = mock_create.call_args[1]
assert call_kwargs["target_branch"] == "@flow"
@patch(f"{MODULE}._parse_date", return_value="")
def test_invalid_date(self, _pd, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_reminder
assert _handle_set_reminder(["xyz", "Some msg"]) is True
mock_err.assert_called()
# =============================================
# _handle_set_schedule
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestHandleSetSchedule:
def test_invalid_type(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
assert _handle_set_schedule(["@branch", "prompt", "weekly"]) is True
mock_err.assert_called()
def test_missing_time_arg(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
assert _handle_set_schedule(["@branch", "prompt", "daily"]) is True
mock_err.assert_called()
def test_interval_non_numeric(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
assert _handle_set_schedule(["@b", "prompt", "interval", "abc"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.create_action")
def test_daily_success(self, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
mock_create.return_value = _make_action(action_id="0070")
assert _handle_set_schedule(["@seedgo", "Run audit", "daily", "04:00"]) is True
kw = mock_create.call_args[1]
assert kw["schedule_type"] == "daily"
assert kw["time"] == "04:00"
@patch(f"{MODULE}.create_action")
def test_hourly_success(self, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
mock_create.return_value = _make_action(action_id="0071")
assert _handle_set_schedule(["@flow", "Check plans", "hourly", "30"]) is True
kw = mock_create.call_args[1]
assert kw["schedule_type"] == "hourly"
@patch(f"{MODULE}.create_action")
def test_interval_success(self, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
mock_create.return_value = _make_action(action_id="0072")
assert _handle_set_schedule(["@daemon", "Heartbeat", "interval", "240"]) is True
kw = mock_create.call_args[1]
assert kw["interval_minutes"] == 240
# =============================================
# _handle_delete
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleDelete:
def test_no_args(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_delete
assert _handle_delete([]) is True
mock_err.assert_called()
@patch(f"{MODULE}.get_action", return_value=None)
def test_not_found(self, _get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_delete
assert _handle_delete(["9999"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.delete_action")
@patch(f"{MODULE}.get_action")
def test_success(self, mock_get, mock_del, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_delete
mock_get.return_value = _make_action(action_id="0005")
assert _handle_delete(["0005"]) is True
mock_del.assert_called_once_with("0005")
# =============================================
# _parse_date
# =============================================
@patch(f"{MODULE}.logger")
class TestParseDate:
def test_relative_days(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
result = _parse_date("7d")
expected = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
assert result == expected
def test_relative_weeks(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
result = _parse_date("2w")
expected = (datetime.now() + timedelta(weeks=2)).strftime("%Y-%m-%d")
assert result == expected
def test_iso_format(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("2026-04-15") == "2026-04-15"
def test_invalid_format(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("not-a-date") == ""
def test_invalid_relative_day(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("xd") == ""
def test_invalid_relative_week(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("xw") == ""
# =============================================
# _format_schedule
# =============================================
class TestFormatSchedule:
def test_daily(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "daily", "time": "08:00"}) == "daily @ 08:00"
def test_hourly(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "hourly", "time": "30"}) == "hourly @ :30"
def test_interval_minutes(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "interval", "interval_minutes": 45}) == "every 45m"
def test_interval_hours(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "interval", "interval_minutes": 120}) == "every 2h"
def test_once(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "once", "due_date": "2026-04-10"}) == "once: 2026-04-10"
def test_unknown_type(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "custom"}) == "custom"
# =============================================
# _route_set_subcommand / _route_action_id
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestRouting:
def test_route_set_too_few_args(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_set_subcommand
assert _route_set_subcommand(["set"]) is True
mock_err.assert_called()
def test_route_set_unknown_type(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_set_subcommand
assert _route_set_subcommand(["set", "bogus"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.get_action", return_value=None)
def test_route_action_id_no_sub_defaults_to_info(self, mock_get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_action_id
assert _route_action_id("0001", ["0001"]) is True
mock_get.assert_called_with("0001")
@patch(f"{MODULE}.get_action")
@patch(f"{MODULE}.toggle_action")
def test_route_action_id_on(self, mock_toggle, mock_get, _err, _con):
from aipass.daemon.apps.modules.actions import _route_action_id
mock_get.return_value = _make_action()
assert _route_action_id("0001", ["0001", "on"]) is True
mock_toggle.assert_called_once_with("0001", True)
def test_route_action_id_unknown_sub(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_action_id
assert _route_action_id("0001", ["0001", "banana"]) is True
mock_err.assert_called()
@@ -0,0 +1,359 @@
# ===================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="@seedgo",
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"] == "@seedgo"
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="@devpulse",
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 is not None
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 is not None
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 (12 hours away from now)."""
from datetime import datetime
now = datetime.now()
# Pick a time 12 hours away — always outside the 15-min fuzzy window
far_hour = (now.hour + 12) % 24
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": f"{far_hour:02d}:00",
"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 is not None
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 is not None
assert action["completed"] is not None
assert action["enabled"] is False
@@ -0,0 +1,243 @@
# =================== AIPass ====================
# Name: test_activity_report.py
# Description: Tests for the activity_report CLI module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for the activity_report CLI module (apps/modules/activity_report.py)."""
from unittest.mock import patch, MagicMock
MODULE = "aipass.daemon.apps.modules.activity_report"
# =============================================
# handle_command -- routing basics
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.error")
@patch(f"{MODULE}.logger")
class TestHandleCommandRouting:
"""Tests for handle_command routing and unknown commands."""
def test_unknown_command_returns_false(self, _log, _err, _con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
assert handle_command("not_a_real_command", []) is False
def test_activity_no_args_calls_generate(self, _log, _err, mock_con, mock_jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report", return_value="report") as mock_gen:
result = handle_command("activity", [])
assert result is True
mock_gen.assert_called_once_with(since_hours=24.0, verbosity="normal")
mock_con.print.assert_called_with("report")
def test_activity_help_shows_help(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report") as mock_gen:
result = handle_command("activity", ["--help"])
assert result is True
mock_gen.assert_not_called()
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("ACTIVITY" in c for c in calls)
def test_activity_hours_48(self, _log, _err, _con, mock_jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report", return_value="report") as mock_gen:
result = handle_command("activity", ["--hours", "48"])
assert result is True
mock_gen.assert_called_once_with(since_hours=48.0, verbosity="normal")
# =============================================
# handle_command -- activity-report
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.error")
@patch(f"{MODULE}.logger")
class TestActivityReportCommand:
"""Tests for 'activity-report' command."""
def test_activity_report_no_args(self, _log, _err, _con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report", return_value="detailed") as mock_gen:
result = handle_command("activity-report", [])
assert result is True
mock_gen.assert_called_once_with(since_hours=24.0, verbosity="detailed")
def test_activity_report_help(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report") as mock_gen:
result = handle_command("activity-report", ["--help"])
assert result is True
mock_gen.assert_not_called()
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("ACTIVITY-REPORT" in c for c in calls)
def test_activity_report_json(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.get_json_report", return_value={"branches": []}) as mock_json:
result = handle_command("activity-report", ["--json"])
assert result is True
mock_json.assert_called_once_with(24.0)
def test_activity_report_json_short_flag(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.get_json_report", return_value={}) as mock_json:
result = handle_command("activity-report", ["-j"])
assert result is True
mock_json.assert_called_once()
# =============================================
# handle_command -- activity_report alias
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.error")
@patch(f"{MODULE}.logger")
class TestActivityReportAlias:
"""Tests for 'activity_report' underscore alias."""
def test_activity_report_alias_works(self, _log, _err, _con, mock_jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report", return_value="r") as mock_gen:
result = handle_command("activity_report", [])
assert result is True
mock_gen.assert_called_once()
def test_activity_report_alias_help(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
result = handle_command("activity_report", ["--help"])
assert result is True
# Shows introspection (module info)
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("activity_report Module" in c for c in calls)
# =============================================
# handle_command -- branch-health
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.error")
@patch(f"{MODULE}.logger")
class TestBranchHealthCommand:
"""Tests for 'branch-health' command."""
def test_branch_health_no_args(self, _log, _err, _con, mock_jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_activity_report", return_value="all") as mock_gen:
result = handle_command("branch-health", [])
assert result is True
mock_gen.assert_called_once_with(since_hours=24, verbosity="normal")
def test_branch_health_help(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
result = handle_command("branch-health", ["--help"])
assert result is True
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("BRANCH-HEALTH" in c for c in calls)
def test_branch_health_with_branch(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_branch_report", return_value="DRONE report") as mock_br:
result = handle_command("branch-health", ["DRONE"])
assert result is True
mock_br.assert_called_once_with("DRONE", since_hours=24.0)
def test_branch_health_with_branch_and_hours(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
with patch(f"{MODULE}.generate_branch_report", return_value="report") as mock_br:
result = handle_command("branch-health", ["DRONE", "--hours", "48"])
assert result is True
mock_br.assert_called_once_with("DRONE", since_hours=48.0)
def test_branch_health_only_flags_shows_error(self, _log, mock_err, mock_con, _jh):
from aipass.daemon.apps.modules.activity_report import handle_command
result = handle_command("branch-health", ["--hours", "48"])
assert result is True
mock_err.assert_called()
# =============================================
# _parse_hours_arg
# =============================================
@patch(f"{MODULE}.logger")
class TestParseHoursArg:
"""Tests for _parse_hours_arg helper."""
def test_hours_flag(self, _log):
from aipass.daemon.apps.modules.activity_report import _parse_hours_arg
assert _parse_hours_arg(["--hours", "48"]) == 48.0
def test_short_flag(self, _log):
from aipass.daemon.apps.modules.activity_report import _parse_hours_arg
assert _parse_hours_arg(["-t", "12"]) == 12.0
def test_no_flag_returns_default(self, _log):
from aipass.daemon.apps.modules.activity_report import _parse_hours_arg
assert _parse_hours_arg([]) == 24.0
def test_invalid_value_returns_default(self, mock_log):
from aipass.daemon.apps.modules.activity_report import _parse_hours_arg
result = _parse_hours_arg(["--hours", "abc"])
assert result == 24.0
mock_log.warning.assert_called()
# =============================================
# _extract_branch_name
# =============================================
class TestExtractBranchName:
"""Tests for _extract_branch_name helper."""
def test_branch_with_flags(self):
from aipass.daemon.apps.modules.activity_report import _extract_branch_name
assert _extract_branch_name(["DRONE", "--hours", "48"]) == "DRONE"
def test_only_flags_returns_none(self):
from aipass.daemon.apps.modules.activity_report import _extract_branch_name
assert _extract_branch_name(["--hours", "48"]) is None
+120
View File
@@ -0,0 +1,120 @@
# =================== AIPass ====================
# Name: test_cli_routing.py
# Description: CLI Routing Tests for DAEMON
# Version: 1.0.0
# Created: 2026-03-28
# Modified: 2026-03-28
# =============================================
"""
CLI Routing Tests for DAEMON branch.
Tests daemon.py routing: help flags, introspection, unknown commands,
no-args behavior, and output capture.
Covers 9 tests:
- help_flag (--help)
- short_help (-h)
- help_word ("help")
- no_args (no arguments)
- unknown_command
- print_help
- print_introspection
- output_capture
- version_flag (bonus)
"""
import sys
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
# We import the daemon module and mock json_handler.log_operation to
# prevent real file writes during routing tests.
# ---------------------------------------------------------------------------
from aipass.daemon.apps import daemon as _daemon_mod
@pytest.fixture(autouse=True)
def _mock_log_operation():
"""Prevent json_handler.log_operation from touching real files."""
with patch.object(_daemon_mod.json_handler, "log_operation", return_value=True):
yield
# ============================================================================
# CLI Routing Tests
# ============================================================================
def test_help_flag() -> None:
"""--help flag triggers help and returns exit code 0."""
with patch.object(sys, "argv", ["daemon", "--help"]):
result = _daemon_mod.main()
assert result == 0, "daemon --help must return exit code 0"
def test_short_help() -> None:
"""short_help: -h flag triggers help and returns exit code 0."""
with patch.object(sys, "argv", ["daemon", "-h"]):
result = _daemon_mod.main()
assert result == 0, "daemon -h must return exit code 0"
def test_help_word() -> None:
"""help_word: 'help' as command triggers help and returns exit code 0."""
with patch.object(sys, "argv", ["daemon", "help"]):
result = _daemon_mod.main()
assert result == 0, "daemon help must return exit code 0"
def test_no_args() -> None:
"""no_args: running daemon with no arguments shows introspection and returns 0."""
with patch.object(sys, "argv", ["daemon"]):
result = _daemon_mod.main()
assert result == 0, "daemon with no args must return exit code 0"
def test_unknown_command() -> None:
"""unknown_command: unrecognized command returns exit code 1."""
with patch.object(sys, "argv", ["daemon", "nonexistent_command_xyz"]):
result = _daemon_mod.main()
assert result == 1, "Unknown command must return exit code 1"
def test_print_help(capsys: pytest.CaptureFixture[str]) -> None:
"""print_help: produces stdout output without error."""
modules = _daemon_mod.get_modules()
_daemon_mod.print_help(modules)
captured = capsys.readouterr()
assert len(captured.out) > 0, "print_help() must produce output"
assert "DAEMON" in captured.out, "print_help output must mention DAEMON"
def test_print_introspection(capsys: pytest.CaptureFixture[str]) -> None:
"""print_introspection: produces stdout output listing modules."""
modules = _daemon_mod.get_modules()
_daemon_mod.print_introspection(modules)
captured = capsys.readouterr()
assert len(captured.out) > 0, "print_introspection() must produce output"
assert "DAEMON" in captured.out, "print_introspection output must mention DAEMON"
def test_output_capture(capsys: pytest.CaptureFixture[str]) -> None:
"""output_capture: help flag produces captured output on stdout."""
with patch.object(sys, "argv", ["daemon", "--help"]):
_daemon_mod.main()
captured = capsys.readouterr()
assert len(captured.out) > 0, "Help output must be capturable on stdout"
assert "USAGE" in captured.out or "daemon" in captured.out.lower(), (
"Captured help output must contain usage information"
)
def test_version_flag() -> None:
"""--version flag returns exit code 0."""
with patch.object(sys, "argv", ["daemon", "--version"]):
result = _daemon_mod.main()
assert result == 0, "daemon --version must return exit code 0"
+313
View File
@@ -0,0 +1,313 @@
# =================== AIPass ====================
# Name: test_contracts.py
# Description: Universal Contracts Test Template (return types, data structures, routing)
# Version: 1.0.0
# Created: 2026-03-28
# Modified: 2026-03-28
# =============================================
"""
Universal Contracts Test Template for DAEMON branch.
Covers tests across 4 groups:
- Return type contracts (4): handle_command_returns_bool (CT-001 via route_command),
paths_return_path, ensure returns bool, load returns dict
- Data structure contracts (3): config_keys, data_keys, log entry
- Success/failure paths (4): known_routes_true, unknown_returns_false,
help_preempts, no_args_triggers
- Infrastructure mocking (3): log entry, sys_modules_mock, reimport_after_mock
"""
import importlib
import json
import sys
import types
from pathlib import Path
from typing import Any
from unittest.mock import patch, MagicMock
import pytest
# ============ BRANCH CONFIG ============
BRANCH_MODULE = "daemon"
# =======================================
# ---------------------------------------------------------------------------
# Dynamic import with cross-branch guard bypass
# ---------------------------------------------------------------------------
_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers"
_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
if _handler_pkg not in sys.modules:
_stub = types.ModuleType(_handler_pkg)
_handlers_dir = (
Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
)
_stub.__path__ = [str(_handlers_dir)]
sys.modules[_handler_pkg] = _stub
_mod = importlib.import_module(_json_mod_path)
json_handler = _mod
# ---------------------------------------------------------------------------
# JSON_DIR variable discovery
# ---------------------------------------------------------------------------
_JSON_DIR_ATTR: str | None = None
_JSON_DIR_CANDIDATES = [
f"{BRANCH_MODULE.upper()}_JSON_DIR",
"JSON_DIR",
"BRANCH_JSON_DIR",
f"{BRANCH_MODULE}_json",
"_JSON_DIR",
]
for _candidate in _JSON_DIR_CANDIDATES:
if hasattr(_mod, _candidate):
_JSON_DIR_ATTR = _candidate
break
if _JSON_DIR_ATTR is None:
pytest.skip(
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- "
f"tried: {_JSON_DIR_CANDIDATES}",
allow_module_level=True,
)
# ---------------------------------------------------------------------------
# Isolation fixture
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect JSON operations to tmp_path for test isolation."""
assert _JSON_DIR_ATTR is not None
original_value = getattr(_mod, _JSON_DIR_ATTR)
if isinstance(original_value, str):
monkeypatch.setattr(_mod, _JSON_DIR_ATTR, str(tmp_path))
else:
monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path)
return tmp_path
# ---------------------------------------------------------------------------
# Default factory helpers
# ---------------------------------------------------------------------------
def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any:
"""Call whichever default factory the branch exposes."""
for fn_name in (
"_create_default",
"_get_default_template",
"_get_default",
"_default_template",
"load_template",
):
fn = getattr(_mod, fn_name, None)
if fn is not None:
return fn(json_type, module_name)
if json_type == "config" and hasattr(_mod, "_default_config"):
return _mod._default_config(module_name)
if json_type == "data" and hasattr(_mod, "_default_data"):
return _mod._default_data(module_name)
if json_type == "log" and hasattr(_mod, "_default_log"):
return _mod._default_log(module_name)
return None
def _default_factory_raises_on_unknown() -> bool:
"""Return True if the default factory raises ValueError for unknown types."""
for fn_name in (
"_create_default",
"_get_default_template",
"_get_default",
"_default_template",
):
fn = getattr(_mod, fn_name, None)
if fn is not None:
try:
fn("__nonexistent_type__", "test_mod")
except ValueError:
return True
except Exception:
return False
return False
return False
# ============================================================================
# Group 1 -- Return type contracts (4 tests)
# ============================================================================
def test_command_returns_bool() -> None:
"""command_returns_bool: route_command returns a bool."""
from aipass.daemon.apps import daemon as _daemon_mod
# Create a mock module that handles the "test" command
mock_module = MagicMock()
mock_module.handle_command.return_value = True
mock_module.__name__ = "mock_module"
result = _daemon_mod.route_command("test", [], [mock_module])
assert isinstance(result, bool), f"route_command must return bool, got {type(result)}"
assert result is True
# Also test the False path
mock_module.handle_command.return_value = False
result = _daemon_mod.route_command("unknown_xyz", [], [mock_module])
assert isinstance(result, bool), f"route_command must return bool, got {type(result)}"
def test_paths_return_path() -> None:
"""paths_return_path: get_json_path returns a Path."""
result = json_handler.get_json_path("contract_mod", "config")
assert isinstance(result, Path), (
f"get_json_path must return Path, got {type(result)}"
)
def test_paths_return_path_for_data() -> None:
"""paths_return_path: get_json_path returns Path for data type too."""
result = json_handler.get_json_path("contract_mod", "data")
assert isinstance(result, Path), (
f"get_json_path('data') must return Path, got {type(result)}"
)
def test_ensure_json_exists_returns_bool(tmp_path: Path) -> None:
"""ensure_json_exists must return a bool."""
result = json_handler.ensure_json_exists("contract_mod", "data")
assert isinstance(result, bool), (
f"ensure_json_exists must return bool, got {type(result)}"
)
assert result is True
def test_load_json_returns_dict_for_config(tmp_path: Path) -> None:
"""load_json for config type must return a dict."""
result = json_handler.load_json("contract_mod", "config")
assert isinstance(result, dict), (
f"load_json('...', 'config') must return dict, got {type(result)}"
)
# ============================================================================
# Group 2 -- Data structure contracts (3 tests)
# ============================================================================
def test_config_keys(tmp_path: Path) -> None:
"""config_keys: config data structure contains module_name and version."""
json_handler.ensure_json_exists("struct_mod", "config")
result = json_handler.load_json("struct_mod", "config")
assert isinstance(result, dict), "Config must be a dict"
assert "module_name" in result, "Config must have 'module_name' key"
assert "version" in result, "Config must have 'version' key"
def test_data_keys(tmp_path: Path) -> None:
"""data_keys: data structure contains created and last_updated."""
json_handler.ensure_json_exists("struct_mod", "data")
result = json_handler.load_json("struct_mod", "data")
assert isinstance(result, dict), "Data must be a dict"
assert "created" in result, "Data must have 'created' key"
assert "last_updated" in result, "Data must have 'last_updated' key"
def test_log_entry_has_operation(tmp_path: Path) -> None:
"""Log entries created by log_operation must contain an 'operation' field."""
json_handler.log_operation("contract_test", module_name="struct_mod")
assert _JSON_DIR_ATTR is not None
val = getattr(_mod, _JSON_DIR_ATTR)
json_dir = Path(val) if isinstance(val, str) else val
log = json.loads(
(json_dir / "struct_mod_log.json").read_text(encoding="utf-8")
)
assert len(log) >= 1, "log_operation must append at least one entry"
assert "operation" in log[-1], "Log entry must have 'operation' key"
assert log[-1]["operation"] == "contract_test"
# ============================================================================
# Group 3 -- Success/failure paths (4 tests)
# ============================================================================
def test_known_routes_true() -> None:
"""known_routes_true: a module that handles a command causes route_command to return True."""
from aipass.daemon.apps import daemon as _daemon_mod
mock_module = MagicMock()
mock_module.handle_command.return_value = True
mock_module.__name__ = "mock_module"
result = _daemon_mod.route_command("update", [], [mock_module])
assert result is True, "Known route must return True"
def test_unknown_returns_false() -> None:
"""unknown_returns_false: no module handles the command so route_command returns False."""
from aipass.daemon.apps import daemon as _daemon_mod
mock_module = MagicMock()
mock_module.handle_command.return_value = False
mock_module.__name__ = "mock_module"
result = _daemon_mod.route_command("nonexistent_xyz_command", [], [mock_module])
assert result is False, "Unknown command must return False"
def test_help_preempts() -> None:
"""help_preempts: --help exits before routing to modules."""
from aipass.daemon.apps import daemon as _daemon_mod
with patch.object(_daemon_mod.json_handler, "log_operation", return_value=True):
with patch.object(sys, "argv", ["daemon", "--help"]):
result = _daemon_mod.main()
assert result == 0, "--help must return 0 before any module routing"
def test_no_args_triggers() -> None:
"""no_args_triggers: no arguments triggers introspection display."""
from aipass.daemon.apps import daemon as _daemon_mod
with patch.object(_daemon_mod.json_handler, "log_operation", return_value=True):
with patch.object(sys, "argv", ["daemon"]):
result = _daemon_mod.main()
assert result == 0, "No args must trigger introspection and return 0"
# ============================================================================
# Group 4 -- Infrastructure mocking (3 tests)
# ============================================================================
def test_log_operation_mocked(tmp_path: Path) -> None:
"""Infrastructure: log_operation can be mocked without side effects."""
with patch.object(_mod, "log_operation", return_value=True) as mock_log:
result = mock_log("test_op", {"data": "value"})
mock_log.assert_called_once_with("test_op", {"data": "value"})
assert result is True
def test_sys_modules_mock() -> None:
"""sys_modules_mock: json_handler module is accessible via sys.modules."""
mod_key = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
assert mod_key in sys.modules, f"{mod_key} must be in sys.modules"
loaded = sys.modules[mod_key]
assert hasattr(loaded, "load_json"), "Module must have load_json function"
assert hasattr(loaded, "save_json"), "Module must have save_json function"
def test_reimport_after_mock(tmp_path: Path) -> None:
"""reimport_after_mock: module can be reloaded cleanly."""
handler_module = sys.modules.get(
f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
)
if handler_module:
importlib.reload(handler_module)
+309
View File
@@ -0,0 +1,309 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_data_loader.py - Data Loader Tests
# Date: 2026-03-24
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-24): Initial creation - data_loader handler tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Temp dir isolation (no reads from real data files)
# =============================================
"""Tests for the data_loader handler."""
import json
from pathlib import Path
import pytest
from aipass.daemon.apps.handlers.update import data_loader as _dl_mod
load_inbox = _dl_mod.load_inbox
load_local = _dl_mod.load_local
categorize_messages = _dl_mod.categorize_messages
get_session_summary = _dl_mod.get_session_summary
get_escalations = _dl_mod.get_escalations
# =============================================
# FIXTURES
# =============================================
@pytest.fixture(autouse=True)
def isolate_paths(tmp_path, monkeypatch):
"""Redirect INBOX_PATH and LOCAL_PATH to tmp_path for every test."""
inbox = tmp_path / "inbox.json"
local = tmp_path / "DAEMON.local.json"
monkeypatch.setattr(_dl_mod, "INBOX_PATH", inbox)
monkeypatch.setattr(_dl_mod, "LOCAL_PATH", local)
yield {"inbox": inbox, "local": local}
@pytest.fixture()
def sample_inbox_data():
"""Standard inbox payload for reuse across tests."""
return {
"mailbox": "inbox",
"total_messages": 2,
"unread_count": 1,
"messages": [
{"id": "abc123", "status": "new", "subject": "Test", "from": "@devpulse", "priority": "normal"},
{"id": "def456", "status": "opened", "subject": "FYI", "from": "@drone", "priority": "normal"},
],
}
@pytest.fixture()
def sample_local_data():
"""Standard local.json payload for reuse across tests."""
return {
"document_metadata": {"version": "1.0.0"},
"sessions": [
{"session_number": 1, "date": "2026-03-01", "summary": "Initial setup", "status": "completed"},
],
"active_tasks": {"current_plan": "Test plan"},
}
def _write_json(path: Path, data: object) -> None:
"""Helper to write JSON to a path."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
# =============================================
# LOAD INBOX TESTS
# =============================================
class TestLoadInbox:
def test_load_valid_inbox(self, isolate_paths, sample_inbox_data, monkeypatch):
"""Loading a well-formed inbox.json returns its full contents."""
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
_write_json(isolate_paths["inbox"], sample_inbox_data)
result = load_inbox()
assert result["mailbox"] == "inbox"
assert result["total_messages"] == 2
assert len(result["messages"]) == 2
def test_load_inbox_missing_file(self, isolate_paths, monkeypatch):
"""Missing inbox.json returns empty default structure."""
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
result = load_inbox()
assert result == {"messages": [], "total_messages": 0, "unread_count": 0}
def test_load_inbox_malformed_json(self, isolate_paths, monkeypatch):
"""Malformed JSON falls back to empty default structure."""
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
isolate_paths["inbox"].write_text("{not valid json!!!", encoding="utf-8")
result = load_inbox()
assert result == {"messages": [], "total_messages": 0, "unread_count": 0}
def test_load_inbox_empty_messages(self, isolate_paths, monkeypatch):
"""Inbox with zero messages returns its original data."""
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
data = {"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []}
_write_json(isolate_paths["inbox"], data)
result = load_inbox()
assert result["messages"] == []
assert result["total_messages"] == 0
# =============================================
# LOAD LOCAL TESTS
# =============================================
class TestLoadLocal:
def test_load_valid_local(self, isolate_paths, sample_local_data):
"""Loading a well-formed local.json returns its full contents."""
_write_json(isolate_paths["local"], sample_local_data)
result = load_local()
assert result["document_metadata"]["version"] == "1.0.0"
assert len(result["sessions"]) == 1
assert result["active_tasks"]["current_plan"] == "Test plan"
def test_load_local_missing_file(self, isolate_paths):
"""Missing local.json returns empty default structure."""
result = load_local()
assert result == {"sessions": [], "active_tasks": {}}
def test_load_local_malformed_json(self, isolate_paths):
"""Malformed JSON falls back to empty default structure."""
isolate_paths["local"].write_text("<<<bad>>>", encoding="utf-8")
result = load_local()
assert result == {"sessions": [], "active_tasks": {}}
def test_load_local_empty_sessions(self, isolate_paths):
"""Local file with empty sessions still loads correctly."""
data = {"sessions": [], "active_tasks": {}}
_write_json(isolate_paths["local"], data)
result = load_local()
assert result["sessions"] == []
assert result["active_tasks"] == {}
# =============================================
# CATEGORIZE MESSAGES TESTS
# =============================================
class TestCategorizeMessages:
def test_new_and_opened_split(self):
"""Messages are split into new and opened buckets by status."""
messages = [
{"id": "1", "status": "new", "subject": "Hello"},
{"id": "2", "status": "opened", "subject": "World"},
]
cats = categorize_messages(messages)
assert len(cats["new"]) == 1
assert cats["new"][0]["id"] == "1"
assert len(cats["opened"]) == 1
assert cats["opened"][0]["id"] == "2"
def test_actionable_keywords(self):
"""Subjects with action keywords land in the actionable bucket."""
messages = [
{"id": "1", "status": "new", "subject": "TASK: Deploy v2"},
{"id": "2", "status": "new", "subject": "BUILD: nightly"},
{"id": "3", "status": "new", "subject": "FIX: broken pipe"},
{"id": "4", "status": "new", "subject": "PROPOSAL: new module"},
{"id": "5", "status": "new", "subject": "REQUEST: access"},
]
cats = categorize_messages(messages)
assert len(cats["actionable"]) == 5
def test_informational_keywords(self):
"""Subjects with info keywords land in the informational bucket."""
messages = [
{"id": "1", "status": "new", "subject": "FYI: update deployed"},
{"id": "2", "status": "opened", "subject": "RE: earlier thread"},
{"id": "3", "status": "new", "subject": "INFO dashboard ready"},
{"id": "4", "status": "new", "subject": "NOTIFICATION: backup done"},
]
cats = categorize_messages(messages)
assert len(cats["informational"]) == 4
def test_message_can_appear_in_multiple_categories(self):
"""A new message with an actionable subject appears in both new and actionable."""
messages = [
{"id": "1", "status": "new", "subject": "TASK: urgent fix"},
]
cats = categorize_messages(messages)
assert len(cats["new"]) == 1
assert len(cats["actionable"]) == 1
assert cats["new"][0] is cats["actionable"][0]
def test_empty_messages(self):
"""Empty message list returns all empty categories."""
cats = categorize_messages([])
assert cats == {"new": [], "opened": [], "actionable": [], "informational": []}
def test_unknown_status_defaults_to_new(self):
"""A message with no status field defaults to new bucket."""
messages = [{"id": "1", "subject": "No status field"}]
cats = categorize_messages(messages)
assert len(cats["new"]) == 1
def test_unrecognised_status_skips_status_buckets(self):
"""A message with a status other than new/opened does not land in status buckets."""
messages = [{"id": "1", "status": "closed", "subject": "Done"}]
cats = categorize_messages(messages)
assert len(cats["new"]) == 0
assert len(cats["opened"]) == 0
# =============================================
# GET SESSION SUMMARY TESTS
# =============================================
class TestGetSessionSummary:
def test_summary_with_sessions(self, sample_local_data):
"""Session summary extracts totals and latest session."""
result = get_session_summary(sample_local_data)
assert result["total_sessions"] == 1
assert result["latest_session"]["session_number"] == 1
def test_summary_empty_sessions(self):
"""Empty sessions list yields zero count and None latest."""
result = get_session_summary({"sessions": [], "active_tasks": {}})
assert result["total_sessions"] == 0
assert result["latest_session"] is None
def test_summary_today_focus(self):
"""today_focus is extracted from active_tasks when present."""
data = {"sessions": [], "active_tasks": {"today_focus": "Write tests"}}
result = get_session_summary(data)
assert result["today_focus"] == "Write tests"
def test_summary_today_focus_default(self):
"""today_focus falls back to 'None' string when absent."""
data = {"sessions": [], "active_tasks": {}}
result = get_session_summary(data)
assert result["today_focus"] == "None"
def test_summary_recently_completed(self):
"""recently_completed list is extracted from active_tasks."""
data = {"sessions": [], "active_tasks": {"recently_completed": ["task-a", "task-b"]}}
result = get_session_summary(data)
assert result["recently_completed"] == ["task-a", "task-b"]
def test_summary_recently_completed_default(self):
"""recently_completed defaults to empty list when absent."""
data = {"sessions": [], "active_tasks": {}}
result = get_session_summary(data)
assert result["recently_completed"] == []
# =============================================
# GET ESCALATIONS TESTS
# =============================================
class TestGetEscalations:
def test_urgent_message_detected(self):
"""Messages with URGENT in subject are escalated."""
messages = [
{"id": "1", "subject": "URGENT: seedgo audit failed"},
{"id": "2", "subject": "Normal update"},
]
result = get_escalations(messages)
assert len(result) == 1
assert result[0]["id"] == "1"
def test_blocked_message_detected(self):
"""Messages with BLOCKED in subject are escalated."""
messages = [
{"id": "1", "subject": "BLOCKED: waiting on upstream"},
]
result = get_escalations(messages)
assert len(result) == 1
assert result[0]["id"] == "1"
def test_no_escalations(self):
"""Messages without escalation keywords return empty list."""
messages = [
{"id": "1", "subject": "FYI: all clear"},
{"id": "2", "subject": "RE: weekly sync"},
]
result = get_escalations(messages)
assert result == []
def test_empty_messages(self):
"""Empty message list returns empty escalations."""
assert get_escalations([]) == []
def test_case_insensitive_detection(self):
"""Escalation keywords are detected case-insensitively."""
messages = [
{"id": "1", "subject": "urgent build failure"},
{"id": "2", "subject": "Blocked on review"},
]
result = get_escalations(messages)
assert len(result) == 2
def test_missing_subject_field(self):
"""Messages without a subject field are not escalated."""
messages = [{"id": "1"}]
result = get_escalations(messages)
assert result == []
@@ -0,0 +1,170 @@
# =================== AIPass ====================
# Name: test_error_resilience.py
# Description: Universal Error Resilience Test Template
# Version: 1.0.0
# Created: 2026-03-28
# Modified: 2026-03-28
# =============================================
"""
Universal Error Resilience Test Template for DAEMON branch.
Covers 4 tests:
- test_missing_file: FileNotFoundError or graceful default on missing file
- test_corrupt_json: JSONDecodeError handled, file regenerated
- test_empty_file: empty content handled gracefully
- test_nonexistent_dir: missing directory handled gracefully
"""
import importlib
import json
import sys
import types
from pathlib import Path
import pytest
# ============ BRANCH CONFIG ============
BRANCH_MODULE = "daemon"
# =======================================
# ---------------------------------------------------------------------------
# Dynamic import with cross-branch guard bypass
# ---------------------------------------------------------------------------
_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers"
_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
if _handler_pkg not in sys.modules:
_stub = types.ModuleType(_handler_pkg)
_handlers_dir = (
Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
)
_stub.__path__ = [str(_handlers_dir)]
sys.modules[_handler_pkg] = _stub
_mod = importlib.import_module(_json_mod_path)
json_handler = _mod
# ---------------------------------------------------------------------------
# JSON_DIR variable discovery
# ---------------------------------------------------------------------------
_JSON_DIR_ATTR: str | None = None
_JSON_DIR_CANDIDATES = [
f"{BRANCH_MODULE.upper()}_JSON_DIR",
"JSON_DIR",
"BRANCH_JSON_DIR",
f"{BRANCH_MODULE}_json",
"_JSON_DIR",
]
for _candidate in _JSON_DIR_CANDIDATES:
if hasattr(_mod, _candidate):
_JSON_DIR_ATTR = _candidate
break
if _JSON_DIR_ATTR is None:
pytest.skip(
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- "
f"tried: {_JSON_DIR_CANDIDATES}",
allow_module_level=True,
)
# ---------------------------------------------------------------------------
# Isolation fixture
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect JSON operations to tmp_path for test isolation."""
assert _JSON_DIR_ATTR is not None
original_value = getattr(_mod, _JSON_DIR_ATTR)
if isinstance(original_value, str):
monkeypatch.setattr(_mod, _JSON_DIR_ATTR, str(tmp_path))
else:
monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path)
return tmp_path
def _json_dir_as_path(tmp_path: Path) -> Path:
"""Return the patched JSON dir as a Path."""
assert _JSON_DIR_ATTR is not None
val = getattr(_mod, _JSON_DIR_ATTR)
if isinstance(val, str):
return Path(val)
return val
# ============================================================================
# Error Resilience Tests (4 tests)
# ============================================================================
def test_missing_file(tmp_path: Path) -> None:
"""Loading a non-existent file returns a graceful default, not a crash."""
json_dir = _json_dir_as_path(tmp_path)
target = json_dir / "ghost_config.json"
assert not target.exists(), "Precondition: file must not exist"
try:
result = json_handler.load_json("ghost", "config")
except FileNotFoundError:
return
assert result is not None, "load_json must not return None for missing file"
assert isinstance(result, dict), "Auto-created config must be a dict"
def test_corrupt_json(tmp_path: Path) -> None:
"""Corrupt JSON on disk is handled gracefully -- file is regenerated."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
target = json_dir / "corrupt_data.json"
target.write_bytes(b"\x00\x01NOT-JSON{{{broken")
result = json_handler.ensure_json_exists("corrupt", "data")
assert result is True, "ensure_json_exists must return True after healing"
raw = target.read_text(encoding="utf-8")
data = json.loads(raw)
assert isinstance(data, dict), "Regenerated data file must be a dict"
assert "created" in data, "Regenerated data must have 'created' key"
assert "last_updated" in data, "Regenerated data must have 'last_updated' key"
def test_empty_file(tmp_path: Path) -> None:
"""An empty file (0 bytes) is handled gracefully."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
target = json_dir / "empty_log.json"
target.write_text("", encoding="utf-8")
result = json_handler.ensure_json_exists("empty", "log")
assert result is True, "ensure_json_exists must return True after healing empty file"
raw = target.read_text(encoding="utf-8")
data = json.loads(raw)
assert isinstance(data, list), "Regenerated log file must be a list"
def test_nonexistent_dir(tmp_path: Path) -> None:
"""Missing parent directory is handled gracefully."""
json_dir = tmp_path / "does_not_exist" / "nested"
assert not json_dir.exists(), "Precondition: directory must not exist"
assert _JSON_DIR_ATTR is not None
original_value = getattr(_mod, _JSON_DIR_ATTR)
if isinstance(original_value, str):
setattr(_mod, _JSON_DIR_ATTR, str(json_dir))
else:
setattr(_mod, _JSON_DIR_ATTR, json_dir)
try:
result = json_handler.ensure_json_exists("nodir", "config")
assert json_dir.exists(), "Handler must create missing directories"
assert result is True
except (FileNotFoundError, OSError):
pass
@@ -0,0 +1,621 @@
# =================== AIPass ====================
# Name: test_json_handler.py
# Description: Universal JSON Handler Test Template
# Version: 1.0.0
# Created: 2026-03-28
# Modified: 2026-03-28
# =============================================
"""
Universal JSON Handler Test Template for DAEMON branch.
Covers 8 groups:
- _default_template / default factory (4)
- validate_json_structure (10)
- get_json_path (3)
- ensure_json_exists (5)
- load_json (4)
- save_json (5)
- log_operation (7)
- ensure_module_jsons (5)
"""
import importlib
import json
import sys
import types
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
# ============ BRANCH CONFIG ============
BRANCH_MODULE = "daemon"
# =======================================
# ---------------------------------------------------------------------------
# Dynamic import with cross-branch guard bypass
# ---------------------------------------------------------------------------
_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers"
_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler"
if _handler_pkg not in sys.modules:
_stub = types.ModuleType(_handler_pkg)
_handlers_dir = (
Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers"
)
_stub.__path__ = [str(_handlers_dir)]
sys.modules[_handler_pkg] = _stub
_mod = importlib.import_module(_json_mod_path)
json_handler = _mod
# ---------------------------------------------------------------------------
# JSON_DIR variable discovery
# ---------------------------------------------------------------------------
_JSON_DIR_ATTR: str | None = None
_JSON_DIR_CANDIDATES = [
f"{BRANCH_MODULE.upper()}_JSON_DIR",
"JSON_DIR",
"BRANCH_JSON_DIR",
f"{BRANCH_MODULE}_json",
"_JSON_DIR",
]
for _candidate in _JSON_DIR_CANDIDATES:
if hasattr(_mod, _candidate):
_JSON_DIR_ATTR = _candidate
break
if _JSON_DIR_ATTR is None:
pytest.skip(
f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler -- "
f"tried: {_JSON_DIR_CANDIDATES}",
allow_module_level=True,
)
# ---------------------------------------------------------------------------
# Default factory discovery
# ---------------------------------------------------------------------------
def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any:
"""Call whichever default factory the branch exposes."""
for fn_name in (
"_create_default",
"_get_default_template",
"_get_default",
"_default_template",
"load_template",
):
fn = getattr(_mod, fn_name, None)
if fn is not None:
return fn(json_type, module_name)
if json_type == "config" and hasattr(_mod, "_default_config"):
return _mod._default_config(module_name)
if json_type == "data" and hasattr(_mod, "_default_data"):
return _mod._default_data(module_name)
if json_type == "log" and hasattr(_mod, "_default_log"):
return _mod._default_log(module_name)
return None
def _has_default_factory() -> bool:
"""Return True if the branch has any callable default factory."""
for fn_name in (
"_create_default",
"_get_default_template",
"_get_default",
"_default_template",
"load_template",
"_default_config",
):
if hasattr(_mod, fn_name):
return True
return False
def _default_factory_raises_on_unknown() -> bool:
"""Return True if the default factory raises ValueError for unknown types."""
for fn_name in (
"_create_default",
"_get_default_template",
"_get_default",
"_default_template",
):
fn = getattr(_mod, fn_name, None)
if fn is not None:
try:
fn("__nonexistent_type__", "test_mod")
except ValueError:
return True
except Exception:
return False
return False
return False
# ---------------------------------------------------------------------------
# Isolation fixture
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect JSON operations to tmp_path for test isolation."""
assert _JSON_DIR_ATTR is not None
original_value = getattr(_mod, _JSON_DIR_ATTR)
if isinstance(original_value, str):
monkeypatch.setattr(_mod, _JSON_DIR_ATTR, str(tmp_path))
else:
monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path)
return tmp_path
def _json_dir_as_path(tmp_path: Path) -> Path:
"""Return the patched JSON dir as a Path."""
assert _JSON_DIR_ATTR is not None
val = getattr(_mod, _JSON_DIR_ATTR)
if isinstance(val, str):
return Path(val)
return val
# ============================================================================
# Group 1 -- default_factory (4 tests)
# ============================================================================
def test_default_factory_config_returns_dict() -> None:
"""default_factory: config template returns a dict with required keys."""
if not _has_default_factory():
pytest.skip("Branch has no default factory function")
result = _get_default_for_type("config", "test_mod")
assert isinstance(result, dict), "Config default must be a dict"
assert "module_name" in result, "Config default must have module_name"
assert "version" in result, "Config default must have version"
assert "config" in result, "Config default must have config"
def test_default_factory_data_returns_dict() -> None:
"""default_factory: data template returns a dict with date keys."""
if not _has_default_factory():
pytest.skip("Branch has no default factory function")
result = _get_default_for_type("data", "test_mod")
assert isinstance(result, dict), "Data default must be a dict"
assert "created" in result, "Data default must have created"
assert "last_updated" in result, "Data default must have last_updated"
def test_default_factory_log_returns_empty_list() -> None:
"""default_factory: log template returns an empty list."""
if not _has_default_factory():
pytest.skip("Branch has no default factory function")
result = _get_default_for_type("log", "test_mod")
assert isinstance(result, list), "Log default must be a list"
assert len(result) == 0, "Log default must be empty"
def test_default_factory_unknown_type_raises() -> None:
"""default_factory: unknown json_type raises ValueError."""
if not _default_factory_raises_on_unknown():
pytest.skip("Branch default factory does not raise ValueError for unknown types")
with pytest.raises(ValueError, match="[Uu]nknown"):
_get_default_for_type("__nonexistent__", "test_mod")
# ============================================================================
# Group 2 -- validate (10 tests)
# ============================================================================
def test_validate_valid_config() -> None:
"""validate: valid config structure passes."""
data = {"module_name": "x", "version": "1.0.0", "config": {}}
assert json_handler.validate_json_structure(data, "config") is True
def test_validate_config_missing_key() -> None:
"""validate: config missing required key fails."""
data = {"module_name": "x", "version": "1.0.0"}
assert json_handler.validate_json_structure(data, "config") is False
def test_validate_config_not_dict() -> None:
"""validate: non-dict config fails."""
assert json_handler.validate_json_structure([1, 2, 3], "config") is False
def test_validate_valid_data() -> None:
"""validate: valid data structure passes."""
data = {"created": "2026-01-01", "last_updated": "2026-01-01"}
assert json_handler.validate_json_structure(data, "data") is True
def test_validate_data_missing_key() -> None:
"""validate: data missing required key fails."""
data = {"created": "2026-01-01"}
assert json_handler.validate_json_structure(data, "data") is False
def test_validate_data_not_dict() -> None:
"""validate: non-dict data fails."""
assert json_handler.validate_json_structure("not a dict", "data") is False
def test_validate_valid_log() -> None:
"""validate: valid log structure (list) passes."""
assert json_handler.validate_json_structure([], "log") is True
assert json_handler.validate_json_structure([{"entry": 1}], "log") is True
def test_validate_log_not_list() -> None:
"""validate: non-list log fails."""
assert json_handler.validate_json_structure({"not": "a list"}, "log") is False
def test_validate_unknown_type_returns_false() -> None:
"""validate: unknown json_type returns False."""
assert json_handler.validate_json_structure({}, "nonexistent_type") is False
def test_validate_none_input_returns_false() -> None:
"""validate: None input returns False for all types."""
assert json_handler.validate_json_structure(None, "config") is False
assert json_handler.validate_json_structure(None, "data") is False
assert json_handler.validate_json_structure(None, "log") is False
# ============================================================================
# Group 3 -- get_path (3 tests)
# ============================================================================
def test_get_path_returns_path_type(tmp_path: Path) -> None:
"""get_path: returns Path or str."""
result = json_handler.get_json_path("mymod", "config")
assert isinstance(result, (Path, str)), "get_json_path must return Path or str"
def test_get_path_filename_pattern(tmp_path: Path) -> None:
"""get_path: filename follows module_type.json pattern."""
result = json_handler.get_json_path("mymod", "config")
name = Path(result).name if isinstance(result, str) else result.name
assert name == "mymod_config.json", f"Expected mymod_config.json, got {name}"
def test_get_path_different_combos_differ(tmp_path: Path) -> None:
"""get_path: different module/type combos produce different paths."""
path_a = str(json_handler.get_json_path("alpha", "log"))
path_b = str(json_handler.get_json_path("beta", "data"))
assert path_a != path_b, "Different module/type combos must produce different paths"
# ============================================================================
# Group 4 -- ensure_exists (5 tests)
# ============================================================================
def test_ensure_exists_creates_file(tmp_path: Path) -> None:
"""ensure_exists: creates file when missing."""
result = json_handler.ensure_json_exists("ens_mod", "config")
assert result is True
json_dir = _json_dir_as_path(tmp_path)
created = json_dir / "ens_mod_config.json"
assert created.exists(), "ensure_json_exists must create the file"
def test_ensure_exists_preserves_valid(tmp_path: Path) -> None:
"""ensure_exists: does not overwrite valid existing file."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
target = json_dir / "keep_data.json"
original = {
"created": "2025-01-01",
"last_updated": "2025-06-01",
"custom_key": "preserve_me",
}
target.write_text(json.dumps(original), encoding="utf-8")
json_handler.ensure_json_exists("keep", "data")
data = json.loads(target.read_text(encoding="utf-8"))
assert data["custom_key"] == "preserve_me", "Valid existing file must not be overwritten"
def test_ensure_exists_regenerates_corrupt(tmp_path: Path) -> None:
"""ensure_exists: regenerates corrupt JSON file."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
target = json_dir / "bad_log.json"
target.write_bytes(b"\x00\x01NOT VALID JSON{{{")
json_handler.ensure_json_exists("bad", "log")
data = json.loads(target.read_text(encoding="utf-8"))
assert isinstance(data, list), "Corrupt JSON must be regenerated to valid log (list)"
def test_ensure_exists_regenerates_invalid_structure(tmp_path: Path) -> None:
"""ensure_exists: regenerates file with invalid structure."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
target = json_dir / "wrong_config.json"
target.write_text(json.dumps({"wrong": "structure"}), encoding="utf-8")
json_handler.ensure_json_exists("wrong", "config")
data = json.loads(target.read_text(encoding="utf-8"))
assert "module_name" in data, "Invalid structure must be regenerated with correct keys"
assert "version" in data
assert "config" in data
def test_ensure_exists_returns_bool(tmp_path: Path) -> None:
"""ensure_exists: returns a bool."""
result = json_handler.ensure_json_exists("bool_mod", "data")
assert isinstance(result, bool), "ensure_json_exists must return bool"
assert result is True
# ============================================================================
# Group 5 -- load (4 tests)
# ============================================================================
def test_load_creates_default_when_missing(tmp_path: Path) -> None:
"""load: auto-creates default when file is missing."""
result = json_handler.load_json("fresh_mod", "log")
assert result is not None, "load_json must auto-create and return content"
assert isinstance(result, list), "Default log must be a list"
def test_load_returns_existing_content(tmp_path: Path) -> None:
"""load: returns content of existing file."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
payload = {"created": "2025-01-01", "last_updated": "2025-06-15", "x": 42}
target = json_dir / "exist_data.json"
target.write_text(json.dumps(payload), encoding="utf-8")
result = json_handler.load_json("exist", "data")
assert isinstance(result, dict)
assert result["x"] == 42, "load_json must return existing file content"
def test_load_returns_dict_for_config(tmp_path: Path) -> None:
"""load: config type returns a dict."""
result = json_handler.load_json("cfg_mod", "config")
assert isinstance(result, dict), "load_json for config must return dict"
def test_load_returns_list_for_log(tmp_path: Path) -> None:
"""load: log type returns a list."""
result = json_handler.load_json("log_mod", "log")
assert isinstance(result, list), "load_json for log must return list"
# ============================================================================
# Group 6 -- save (5 tests)
# ============================================================================
def test_save_roundtrip(tmp_path: Path) -> None:
"""save: data survives save-then-load roundtrip."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
data = {"module_name": "rt", "version": "1.0.0", "config": {"key": "val"}}
json_handler.save_json("rt", "config", data)
loaded = json_handler.load_json("rt", "config")
assert loaded is not None
assert loaded["config"]["key"] == "val", "Saved data must be readable via load_json"
def test_save_returns_true(tmp_path: Path) -> None:
"""save: returns True on success."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
data = {"module_name": "sv", "version": "1.0.0", "config": {}}
result = json_handler.save_json("sv", "config", data)
assert result is True, "save_json must return True on success"
def test_save_rejects_invalid_structure(tmp_path: Path) -> None:
"""save: raises ValueError for invalid structure."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
with pytest.raises(ValueError, match="[Ii]nvalid"):
json_handler.save_json("bad", "config", {"missing": "keys"})
def test_save_data_updates_last_updated(tmp_path: Path) -> None:
"""save: auto-stamps last_updated on data type."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().date().isoformat()
data = {"created": "2025-01-01", "last_updated": "2025-01-01"}
json_handler.save_json("ts", "data", data)
on_disk = json.loads(
(json_dir / "ts_data.json").read_text(encoding="utf-8")
)
assert on_disk["last_updated"] == today, "Saving data type must auto-stamp last_updated"
def test_save_writes_valid_json(tmp_path: Path) -> None:
"""save: writes valid parseable JSON to disk."""
json_dir = _json_dir_as_path(tmp_path)
json_dir.mkdir(parents=True, exist_ok=True)
entries = [{"timestamp": "t1", "operation": "test"}]
json_handler.save_json("disk", "log", entries)
raw = (json_dir / "disk_log.json").read_text(encoding="utf-8")
parsed = json.loads(raw)
assert isinstance(parsed, list), "Saved file must be valid JSON on disk"
assert len(parsed) == 1
# ============================================================================
# Group 7 -- log_operation (7 tests)
# ============================================================================
def test_log_operation_appends_entry(tmp_path: Path) -> None:
"""log_operation appends an entry to the log file."""
json_handler.log_operation("deploy", module_name="logmod")
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "logmod_log.json").read_text(encoding="utf-8")
)
assert len(log) >= 1, "log_operation must append at least one entry"
assert log[-1]["operation"] == "deploy"
def test_log_operation_returns_bool(tmp_path: Path) -> None:
"""log_operation returns a bool."""
result = json_handler.log_operation("test_op", module_name="boolmod")
assert isinstance(result, bool), "log_operation must return bool"
assert result is True
def test_log_operation_entry_has_timestamp(tmp_path: Path) -> None:
"""log_operation entries include a timestamp field."""
json_handler.log_operation("check_ts", module_name="tsmod")
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "tsmod_log.json").read_text(encoding="utf-8")
)
assert "timestamp" in log[-1], "Log entry must have a timestamp field"
def test_log_operation_includes_data(tmp_path: Path) -> None:
"""log_operation includes data dict when provided."""
json_handler.log_operation(
"with_data", data={"count": 5}, module_name="datamod"
)
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "datamod_log.json").read_text(encoding="utf-8")
)
assert "data" in log[-1], "Log entry must include data dict when provided"
assert log[-1]["data"]["count"] == 5
def test_log_operation_multiple_calls_accumulate(tmp_path: Path) -> None:
"""log_operation: multiple calls accumulate entries."""
json_handler.log_operation("first", module_name="accmod")
json_handler.log_operation("second", module_name="accmod")
json_handler.log_operation("third", module_name="accmod")
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "accmod_log.json").read_text(encoding="utf-8")
)
assert len(log) >= 3, "Multiple log_operation calls must accumulate entries"
ops = [e["operation"] for e in log[-3:]]
assert ops == ["first", "second", "third"]
def test_log_operation_fifo_rotation(tmp_path: Path) -> None:
"""log_operation: FIFO rotation trims old entries."""
max_entries = getattr(_mod, "MAX_LOG_ENTRIES", getattr(_mod, "max_log_entries", None))
if max_entries is None:
for attr in ("MAX_LOG_ENTRIES", "max_log_entries", "LOG_MAX_ENTRIES", "_MAX_LOG_ENTRIES"):
max_entries = getattr(_mod, attr, None)
if max_entries is not None:
break
if max_entries is None:
pytest.skip("Cannot find max_log_entries constant on module")
for i in range(max_entries + 5):
json_handler.log_operation(f"op_{i}", module_name="fifomod")
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "fifomod_log.json").read_text(encoding="utf-8")
)
assert len(log) <= max_entries, f"Log must not exceed {max_entries} entries"
assert log[-1]["operation"] == f"op_{max_entries + 4}", "Most recent entry must be last"
def test_log_operation_empty_dict_not_attached(tmp_path: Path) -> None:
"""log_operation: empty dict data should not create non-empty data field."""
json_handler.log_operation("no_data", data={}, module_name="emptymod")
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "emptymod_log.json").read_text(encoding="utf-8")
)
entry = log[-1]
if "data" in entry:
assert entry["data"] == {} or entry["data"] is None
# ============================================================================
# Group 8 -- ensure_module (5 tests)
# ============================================================================
def test_ensure_module_creates_all_three(tmp_path: Path) -> None:
"""ensure_module: creates config, data, and log files."""
if not hasattr(json_handler, "ensure_module_jsons"):
pytest.skip("Branch does not have ensure_module_jsons")
json_handler.ensure_module_jsons("triple")
json_dir = _json_dir_as_path(tmp_path)
assert (json_dir / "triple_config.json").exists(), "Config file must exist"
assert (json_dir / "triple_data.json").exists(), "Data file must exist"
assert (json_dir / "triple_log.json").exists(), "Log file must exist"
def test_ensure_module_returns_true(tmp_path: Path) -> None:
"""ensure_module: returns True on success."""
if not hasattr(json_handler, "ensure_module_jsons"):
pytest.skip("Branch does not have ensure_module_jsons")
result = json_handler.ensure_module_jsons("retmod")
assert result is True, "ensure_module_jsons must return True"
def test_ensure_module_files_pass_validation(tmp_path: Path) -> None:
"""ensure_module: all created files pass validation."""
if not hasattr(json_handler, "ensure_module_jsons"):
pytest.skip("Branch does not have ensure_module_jsons")
json_handler.ensure_module_jsons("valid_mod")
json_dir = _json_dir_as_path(tmp_path)
config = json.loads(
(json_dir / "valid_mod_config.json").read_text(encoding="utf-8")
)
assert json_handler.validate_json_structure(config, "config") is True
data = json.loads(
(json_dir / "valid_mod_data.json").read_text(encoding="utf-8")
)
assert json_handler.validate_json_structure(data, "data") is True
log = json.loads(
(json_dir / "valid_mod_log.json").read_text(encoding="utf-8")
)
assert json_handler.validate_json_structure(log, "log") is True
def test_ensure_module_data_has_correct_keys(tmp_path: Path) -> None:
"""ensure_module: data file has created and last_updated keys."""
if not hasattr(json_handler, "ensure_module_jsons"):
pytest.skip("Branch does not have ensure_module_jsons")
json_handler.ensure_module_jsons("keymod")
json_dir = _json_dir_as_path(tmp_path)
data = json.loads(
(json_dir / "keymod_data.json").read_text(encoding="utf-8")
)
assert "created" in data, "Data file must have 'created' key"
assert "last_updated" in data, "Data file must have 'last_updated' key"
def test_ensure_module_log_is_empty_list(tmp_path: Path) -> None:
"""ensure_module: log file is an empty list."""
if not hasattr(json_handler, "ensure_module_jsons"):
pytest.skip("Branch does not have ensure_module_jsons")
json_handler.ensure_module_jsons("listmod")
json_dir = _json_dir_as_path(tmp_path)
log = json.loads(
(json_dir / "listmod_log.json").read_text(encoding="utf-8")
)
assert isinstance(log, list), "Log file must be a list"
assert len(log) == 0, "Initial log file must be an empty list"
@@ -0,0 +1,478 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_memory_health.py - Memory Health Handler Tests
# Date: 2026-03-24
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-24): Initial creation - memory health tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Temp dir isolation via tmp_path
# =============================================
"""Tests for the memory health handler."""
import json
import os
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from aipass.daemon.apps.handlers.monitoring import memory_health as mh
# =============================================
# HELPERS
# =============================================
def _write_json(path: Path, data: dict) -> None:
"""Write a dict to a JSON file, creating parent dirs."""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f)
def _valid_memory_json() -> dict:
"""Return a valid memory file structure with metadata and limits."""
return {
"document_metadata": {
"document_type": "session_history",
"version": "1.0.0",
"limits": {"max_lines": 600},
},
"sessions": [],
}
def _setup_full_branch(tmp_path: Path) -> Path:
"""Create a fully populated branch directory with all memory files."""
branch = tmp_path / "TESTBRANCH"
trinity = branch / ".trinity"
trinity.mkdir(parents=True)
_write_json(trinity / "local.json", _valid_memory_json())
_write_json(trinity / "observations.json", _valid_memory_json())
(branch / "README.md").write_text("# Test", encoding="utf-8")
_write_json(branch / "DASHBOARD.local.json", {"status": "ok"})
return branch
# =============================================
# FILE EXISTENCE TESTS
# =============================================
class TestCheckMemoryFilesExist:
"""Tests for check_memory_files_exist()."""
def test_all_files_present(self, tmp_path: Path) -> None:
"""All required and optional files present returns clean result."""
branch = _setup_full_branch(tmp_path)
result = mh.check_memory_files_exist(str(branch), "TESTBRANCH")
assert result["all_required_present"] is True
assert result["missing_required"] == []
assert result["missing_optional"] == []
assert result["required"][".trinity/local.json"] is True
assert result["required"]["README.md"] is True
def test_missing_all_files(self, tmp_path: Path) -> None:
"""Empty directory has all files missing."""
branch = tmp_path / "EMPTY"
branch.mkdir()
result = mh.check_memory_files_exist(str(branch), "EMPTY")
assert result["all_required_present"] is False
assert ".trinity/local.json" in result["missing_required"]
assert "README.md" in result["missing_required"]
assert ".trinity/observations.json" in result["missing_optional"]
assert "DASHBOARD.local.json" in result["missing_optional"]
def test_missing_local_json_only(self, tmp_path: Path) -> None:
"""Missing .trinity/local.json flags required missing."""
branch = tmp_path / "PARTIAL"
branch.mkdir()
(branch / "README.md").write_text("# Readme", encoding="utf-8")
result = mh.check_memory_files_exist(str(branch), "PARTIAL")
assert result["all_required_present"] is False
assert ".trinity/local.json" in result["missing_required"]
assert "README.md" not in result["missing_required"]
def test_missing_readme_only(self, tmp_path: Path) -> None:
"""Missing README.md flags required missing."""
branch = tmp_path / "NO_README"
trinity = branch / ".trinity"
trinity.mkdir(parents=True)
_write_json(trinity / "local.json", {})
result = mh.check_memory_files_exist(str(branch), "NO_README")
assert result["all_required_present"] is False
assert "README.md" in result["missing_required"]
assert ".trinity/local.json" not in result["missing_required"]
def test_optional_observations_present(self, tmp_path: Path) -> None:
"""observations.json present removes it from missing_optional."""
branch = tmp_path / "WITH_OBS"
trinity = branch / ".trinity"
trinity.mkdir(parents=True)
_write_json(trinity / "observations.json", {})
result = mh.check_memory_files_exist(str(branch), "WITH_OBS")
assert result["optional"][".trinity/observations.json"] is True
assert ".trinity/observations.json" not in result["missing_optional"]
def test_optional_dashboard_present(self, tmp_path: Path) -> None:
"""DASHBOARD.local.json present removes it from missing_optional."""
branch = tmp_path / "WITH_DASH"
branch.mkdir()
_write_json(branch / "DASHBOARD.local.json", {})
result = mh.check_memory_files_exist(str(branch), "WITH_DASH")
assert result["optional"]["DASHBOARD.local.json"] is True
assert "DASHBOARD.local.json" not in result["missing_optional"]
def test_directory_not_counted_as_file(self, tmp_path: Path) -> None:
"""A directory named README.md should not count as the file."""
branch = tmp_path / "DIR_TRICK"
branch.mkdir()
(branch / "README.md").mkdir() # directory, not file
result = mh.check_memory_files_exist(str(branch), "DIR_TRICK")
assert result["required"]["README.md"] is False
assert "README.md" in result["missing_required"]
# =============================================
# STRUCTURE VALIDATION TESTS
# =============================================
class TestValidateMemoryStructure:
"""Tests for validate_memory_structure()."""
def test_valid_structure_with_document_metadata(self, tmp_path: Path) -> None:
"""Valid file with document_metadata and limits passes."""
f = tmp_path / "valid.json"
_write_json(f, _valid_memory_json())
result = mh.validate_memory_structure(str(f))
assert result["valid"] is True
assert result["has_metadata"] is True
assert result["has_limits"] is True
assert result["issues"] == []
assert "document_type" in result["metadata_fields"]
assert "limits" in result["metadata_fields"]
def test_valid_structure_with_metadata_key(self, tmp_path: Path) -> None:
"""File using 'metadata' key (instead of 'document_metadata') is valid."""
f = tmp_path / "alt_meta.json"
_write_json(f, {
"metadata": {
"version": "1.0.0",
"limits": {"max_entries": 100},
},
})
result = mh.validate_memory_structure(str(f))
assert result["valid"] is True
assert result["has_metadata"] is True
assert result["has_limits"] is True
def test_missing_limits_field(self, tmp_path: Path) -> None:
"""Metadata present but no limits field should report issue."""
f = tmp_path / "no_limits.json"
_write_json(f, {
"document_metadata": {
"document_type": "session_history",
"version": "1.0.0",
},
})
result = mh.validate_memory_structure(str(f))
assert result["valid"] is False
assert result["has_metadata"] is True
assert result["has_limits"] is False
assert any("limits" in issue for issue in result["issues"])
def test_no_metadata_section(self, tmp_path: Path) -> None:
"""File with no metadata section at all."""
f = tmp_path / "bare.json"
_write_json(f, {"sessions": [], "data": "hello"})
result = mh.validate_memory_structure(str(f))
assert result["valid"] is False
assert result["has_metadata"] is False
assert result["has_limits"] is False
assert any("metadata" in issue.lower() for issue in result["issues"])
def test_invalid_json(self, tmp_path: Path) -> None:
"""Malformed JSON returns invalid with error."""
f = tmp_path / "broken.json"
f.write_text("{not valid json", encoding="utf-8")
result = mh.validate_memory_structure(str(f))
assert result["valid"] is False
assert result["has_metadata"] is False
assert any("Invalid JSON" in issue for issue in result["issues"])
def test_nonexistent_file(self, tmp_path: Path) -> None:
"""Nonexistent file path returns invalid."""
result = mh.validate_memory_structure(str(tmp_path / "ghost.json"))
assert result["valid"] is False
assert result["issues"] == ["File does not exist"]
assert result["metadata_fields"] == []
def test_empty_json_object(self, tmp_path: Path) -> None:
"""Empty JSON object {} has no metadata."""
f = tmp_path / "empty.json"
_write_json(f, {})
result = mh.validate_memory_structure(str(f))
assert result["valid"] is False
assert result["has_metadata"] is False
# =============================================
# FRESHNESS TESTS
# =============================================
class TestCheckFreshness:
"""Tests for check_freshness()."""
def test_fresh_file_is_ok(self, tmp_path: Path) -> None:
"""A just-created file should be OK."""
f = tmp_path / "fresh.json"
f.write_text("{}", encoding="utf-8")
result = mh.check_freshness(str(f))
assert result["exists"] is True
assert result["status"] == "OK"
assert result["days_ago"] is not None
assert result["days_ago"] < 1
assert result["last_modified"] is not None
def test_warning_threshold(self, tmp_path: Path) -> None:
"""File older than warning_days but under red_days gives WARNING."""
f = tmp_path / "stale.json"
f.write_text("{}", encoding="utf-8")
# Set mtime to 10 days ago
ten_days_ago = time.time() - (10 * 86400)
os.utime(f, (ten_days_ago, ten_days_ago))
result = mh.check_freshness(str(f), warning_days=7, red_days=30)
assert result["status"] == "WARNING"
assert result["days_ago"] is not None
assert result["days_ago"] > 7
def test_red_threshold(self, tmp_path: Path) -> None:
"""File older than red_days gives RED."""
f = tmp_path / "ancient.json"
f.write_text("{}", encoding="utf-8")
# Set mtime to 45 days ago
old_time = time.time() - (45 * 86400)
os.utime(f, (old_time, old_time))
result = mh.check_freshness(str(f), warning_days=7, red_days=30)
assert result["status"] == "RED"
assert result["days_ago"] is not None
assert result["days_ago"] > 30
def test_nonexistent_file_is_red(self, tmp_path: Path) -> None:
"""Nonexistent file returns RED status."""
result = mh.check_freshness(str(tmp_path / "missing.json"))
assert result["exists"] is False
assert result["status"] == "RED"
assert result["last_modified"] is None
assert result["days_ago"] is None
assert result["message"] == "File does not exist"
def test_custom_thresholds(self, tmp_path: Path) -> None:
"""Custom warning/red thresholds are respected."""
f = tmp_path / "custom.json"
f.write_text("{}", encoding="utf-8")
# Set mtime to 3 days ago
three_days_ago = time.time() - (3 * 86400)
os.utime(f, (three_days_ago, three_days_ago))
# With tight thresholds: warning at 2 days, red at 5 days
result = mh.check_freshness(str(f), warning_days=2, red_days=5)
assert result["status"] == "WARNING"
def test_exactly_at_boundary_uses_ok(self, tmp_path: Path) -> None:
"""File modified exactly now should be OK, not WARNING."""
f = tmp_path / "now.json"
f.write_text("{}", encoding="utf-8")
result = mh.check_freshness(str(f), warning_days=7, red_days=30)
# Setting mtime to "now" yields 0 days ago which is < warning_days
assert result["exists"] is True
assert result["status"] == "OK"
def test_days_ago_is_rounded(self, tmp_path: Path) -> None:
"""days_ago value is a numeric type."""
f = tmp_path / "rounded.json"
f.write_text("{}", encoding="utf-8")
result = mh.check_freshness(str(f))
assert result["days_ago"] is not None
assert isinstance(result["days_ago"], (int, float))
# =============================================
# OVERALL HEALTH STATUS TESTS
# =============================================
class TestGetMemoryHealthStatus:
"""Tests for get_memory_health_status()."""
@pytest.fixture(autouse=True)
def _mock_log_operation(self):
"""Prevent json_handler.log_operation from touching real files."""
with patch.object(mh.json_handler, "log_operation"):
yield
def test_healthy_branch_returns_ok(self, tmp_path: Path) -> None:
"""Branch with all files, valid structure, and fresh data returns OK."""
branch = _setup_full_branch(tmp_path)
result = mh.get_memory_health_status(str(branch), "TESTBRANCH")
assert result["overall_status"] == "OK"
assert result["branch_name"] == "TESTBRANCH"
assert result["branch_path"] == str(branch)
assert result["issues"] == []
assert "check_time" in result
def test_missing_required_file_returns_red(self, tmp_path: Path) -> None:
"""Missing a required file yields RED overall status."""
branch = tmp_path / "NOREQUIRED"
branch.mkdir()
# Only create optional files, no required ones
result = mh.get_memory_health_status(str(branch), "NOREQUIRED")
assert result["overall_status"] == "RED"
assert any("Missing required" in issue for issue in result["issues"])
def test_missing_optional_file_returns_warning(self, tmp_path: Path) -> None:
"""Missing an optional file yields WARNING overall status."""
branch = tmp_path / "NOOPT"
trinity = branch / ".trinity"
trinity.mkdir(parents=True)
_write_json(trinity / "local.json", _valid_memory_json())
(branch / "README.md").write_text("# Test", encoding="utf-8")
# No observations.json, no DASHBOARD.local.json
result = mh.get_memory_health_status(str(branch), "NOOPT")
assert result["overall_status"] == "WARNING"
assert any("Missing optional" in issue for issue in result["issues"])
def test_stale_files_returns_warning(self, tmp_path: Path) -> None:
"""Files older than warning threshold yield WARNING."""
branch = _setup_full_branch(tmp_path)
# Make local.json 10 days old
local_file = branch / ".trinity" / "local.json"
ten_days_ago = time.time() - (10 * 86400)
os.utime(local_file, (ten_days_ago, ten_days_ago))
readme = branch / "README.md"
os.utime(readme, (ten_days_ago, ten_days_ago))
result = mh.get_memory_health_status(str(branch), "STALE")
assert result["overall_status"] == "WARNING"
def test_very_stale_files_returns_red(self, tmp_path: Path) -> None:
"""Files older than red threshold yield RED."""
branch = _setup_full_branch(tmp_path)
# Make local.json 45 days old
local_file = branch / ".trinity" / "local.json"
old_time = time.time() - (45 * 86400)
os.utime(local_file, (old_time, old_time))
result = mh.get_memory_health_status(str(branch), "ANCIENT")
assert result["overall_status"] == "RED"
def test_invalid_structure_promotes_to_warning(self, tmp_path: Path) -> None:
"""Invalid memory structure promotes OK to WARNING."""
branch = tmp_path / "BADSTRUCT"
trinity = branch / ".trinity"
trinity.mkdir(parents=True)
# Write local.json with no metadata (invalid structure)
_write_json(trinity / "local.json", {"sessions": []})
_write_json(trinity / "observations.json", _valid_memory_json())
(branch / "README.md").write_text("# Test", encoding="utf-8")
_write_json(branch / "DASHBOARD.local.json", {"status": "ok"})
result = mh.get_memory_health_status(str(branch), "BADSTRUCT")
assert result["overall_status"] == "WARNING"
assert ".trinity/local.json" in result["structure_checks"]
def test_structure_checks_only_for_existing_files(self, tmp_path: Path) -> None:
"""Structure checks are only performed on files that exist."""
branch = tmp_path / "MINIMAL"
trinity = branch / ".trinity"
trinity.mkdir(parents=True)
_write_json(trinity / "local.json", _valid_memory_json())
(branch / "README.md").write_text("# Test", encoding="utf-8")
result = mh.get_memory_health_status(str(branch), "MINIMAL")
# local.json exists, so it should be checked
assert ".trinity/local.json" in result["structure_checks"]
# observations.json does not exist, so it should not be in structure_checks
assert ".trinity/observations.json" not in result["structure_checks"]
def test_freshness_checks_include_local_and_readme(self, tmp_path: Path) -> None:
"""Freshness checks cover .trinity/local.json and README.md."""
branch = _setup_full_branch(tmp_path)
result = mh.get_memory_health_status(str(branch), "FRESH")
assert ".trinity/local.json" in result["freshness_checks"]
assert "README.md" in result["freshness_checks"]
def test_result_contains_all_expected_keys(self, tmp_path: Path) -> None:
"""Returned dict has all documented keys with correct value types."""
branch = _setup_full_branch(tmp_path)
result = mh.get_memory_health_status(str(branch), "KEYS")
expected_keys = {
"branch_name", "branch_path", "overall_status",
"file_check", "structure_checks", "freshness_checks",
"issues", "check_time",
}
assert expected_keys == set(result.keys())
assert isinstance(result["overall_status"], str)
assert result["overall_status"] in ("OK", "WARNING", "RED")
assert isinstance(result["branch_name"], str)
@@ -0,0 +1,500 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_red_flag_detector.py - Red Flag Detector Tests
# Date: 2026-03-24
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-24): Initial creation - red flag detection engine tests
#
# CODE STANDARDS:
# - Pytest conventions
# - unittest.mock.patch for external dependencies
# =============================================
"""Tests for the red flag detection engine."""
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
import pytest
from aipass.daemon.apps.handlers.monitoring import red_flag_detector
from aipass.daemon.apps.handlers.monitoring.red_flag_detector import (
_parse_iso_datetime,
get_branch_status,
detect_red_flags,
get_red_flag_summary,
STATUS_RED_FLAG,
STATUS_OK,
STATUS_NO_ACTIVITY,
STATUS_ERROR,
)
MOCK_PATCH_ACTIVITY = "aipass.daemon.apps.handlers.monitoring.activity_collector.scan_branch_activity"
MOCK_PATCH_BRANCHES = "aipass.daemon.apps.handlers.monitoring.activity_collector.get_branch_paths"
MOCK_PATCH_JSON_LOG = "aipass.daemon.apps.handlers.monitoring.red_flag_detector.json_handler.log_operation"
def _make_activity(
branch_name: str = "TEST",
code_files: list | None = None,
memory_files: list | None = None,
) -> dict:
"""Build a mock return value for scan_branch_activity."""
if code_files is None:
code_files = []
if memory_files is None:
memory_files = []
all_files = code_files + memory_files
last_activity = None
if all_files:
last_activity = max(f["mtime"] for f in all_files)
return {
"branch_name": branch_name,
"path": f"/fake/path/{branch_name.lower()}",
"code_files": code_files,
"memory_files": memory_files,
"last_activity": last_activity,
"total_files": len(all_files),
"scan_time": datetime.now().isoformat(),
}
# =============================================
# _parse_iso_datetime TESTS
# =============================================
class TestParseIsoDatetime:
"""Tests for ISO datetime string parsing."""
def test_valid_iso_string(self):
"""Parse a standard ISO datetime string."""
result = _parse_iso_datetime("2026-03-20T10:00:00")
assert result is not None
assert isinstance(result, datetime)
assert result.year == 2026
assert result.month == 3
assert result.day == 20
assert result.hour == 10
def test_valid_iso_string_with_microseconds(self):
"""Parse ISO datetime string containing microseconds."""
result = _parse_iso_datetime("2026-03-20T10:30:00.123456")
assert result is not None
assert isinstance(result, datetime)
assert result.microsecond == 123456
def test_valid_iso_date_only(self):
"""Parse a date-only ISO string (no time component)."""
result = _parse_iso_datetime("2026-03-20")
assert result is not None
assert result.year == 2026
assert result.hour == 0
def test_empty_string_returns_none(self):
"""Empty string returns None."""
assert _parse_iso_datetime("") is None
def test_none_returns_none(self):
"""None input returns None (falsy check)."""
assert _parse_iso_datetime(None) is None # type: ignore[arg-type]
def test_invalid_string_returns_none(self):
"""Invalid/garbage string returns None."""
assert _parse_iso_datetime("not-a-date") is None
def test_partial_iso_returns_none(self):
"""Malformed ISO string returns None."""
assert _parse_iso_datetime("2026-13-40T99:99:99") is None
# =============================================
# get_branch_status TESTS
# =============================================
class TestGetBranchStatus:
"""Tests for single-branch status detection."""
@patch(MOCK_PATCH_ACTIVITY)
def test_no_code_changes_returns_no_activity(self, mock_scan):
"""No code files modified -> NO_ACTIVITY status."""
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
result = get_branch_status("TEST", "/fake/path/test")
assert result["status"] == STATUS_NO_ACTIVITY
assert result["branch_name"] == "TEST"
assert result["code_change_count"] == 0
assert "No code changes" in result["reason"]
@patch(MOCK_PATCH_ACTIVITY)
def test_code_changed_memory_updated_after_returns_ok(self, mock_scan):
"""Code changed, memory updated after code -> OK."""
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/app.py", "name": "app.py", "mtime": "2026-03-20T10:00:00"},
],
memory_files=[
{"path": "/fake/.trinity/local.json", "name": "local.json", "mtime": "2026-03-20T12:00:00"},
],
)
result = get_branch_status("TEST", "/fake/path/test")
assert result["status"] == STATUS_OK
assert result["code_change_count"] == 1
assert result["latest_code_change"] is not None
assert result["memory_last_update"] is not None
@patch(MOCK_PATCH_ACTIVITY)
def test_code_changed_memory_at_same_time_returns_ok(self, mock_scan):
"""Code and memory modified at the same timestamp -> OK."""
timestamp = "2026-03-20T10:00:00"
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/app.py", "name": "app.py", "mtime": timestamp},
],
memory_files=[
{"path": "/fake/.trinity/local.json", "name": "local.json", "mtime": timestamp},
],
)
result = get_branch_status("TEST", "/fake/path/test")
assert result["status"] == STATUS_OK
assert result["hours_since_code"] == 0.0
@patch(MOCK_PATCH_ACTIVITY)
def test_code_changed_no_memory_returns_red_flag(self, mock_scan):
"""Code changed but no memory files modified at all -> RED_FLAG."""
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/app.py", "name": "app.py", "mtime": "2026-03-20T10:00:00"},
],
memory_files=[],
)
result = get_branch_status("TEST", "/fake/path/test")
assert result["status"] == STATUS_RED_FLAG
assert result["code_change_count"] == 1
assert "no memory updates" in result["reason"]
@patch(MOCK_PATCH_ACTIVITY)
def test_code_changed_memory_way_before_returns_red_flag(self, mock_scan):
"""Memory updated long before code changes (outside threshold) -> RED_FLAG."""
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/app.py", "name": "app.py", "mtime": "2026-03-20T10:00:00"},
],
memory_files=[
{"path": "/fake/.trinity/local.json", "name": "local.json", "mtime": "2026-03-19T01:00:00"},
],
)
result = get_branch_status("TEST", "/fake/path/test", threshold_hours=2.0)
assert result["status"] == STATUS_RED_FLAG
assert "BEFORE code" in result["reason"]
@patch(MOCK_PATCH_ACTIVITY)
def test_memory_slightly_before_within_threshold_returns_ok(self, mock_scan):
"""Memory updated slightly before code but within threshold -> OK."""
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/app.py", "name": "app.py", "mtime": "2026-03-20T10:00:00"},
],
memory_files=[
{"path": "/fake/.trinity/local.json", "name": "local.json", "mtime": "2026-03-20T09:00:00"},
],
)
# threshold_hours=2.0 means 1 hour before is acceptable
result = get_branch_status("TEST", "/fake/path/test", threshold_hours=2.0)
assert result["status"] == STATUS_OK
assert "within threshold" in result["reason"].lower()
@patch(MOCK_PATCH_ACTIVITY)
def test_scanner_exception_returns_error(self, mock_scan):
"""If scan_branch_activity raises an exception -> ERROR status."""
mock_scan.side_effect = RuntimeError("disk on fire")
result = get_branch_status("TEST", "/fake/path/test")
assert result["status"] == STATUS_ERROR
assert "disk on fire" in result["reason"]
@patch(MOCK_PATCH_ACTIVITY)
def test_multiple_code_files_uses_latest(self, mock_scan):
"""When multiple code files exist, the latest mtime drives the decision."""
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/a.py", "name": "a.py", "mtime": "2026-03-20T08:00:00"},
{"path": "/fake/b.py", "name": "b.py", "mtime": "2026-03-20T14:00:00"},
],
memory_files=[
{"path": "/fake/.trinity/local.json", "name": "local.json", "mtime": "2026-03-20T15:00:00"},
],
)
result = get_branch_status("TEST", "/fake/path/test")
assert result["status"] == STATUS_OK
assert result["code_change_count"] == 2
# latest_code_change should be the 14:00 file
assert "14:00:00" in result["latest_code_change"]
@patch(MOCK_PATCH_ACTIVITY)
def test_result_dict_has_required_keys(self, mock_scan):
"""Verify all expected keys are present in the returned dict."""
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
result = get_branch_status("TEST", "/fake/path/test")
required_keys = {
"branch_name", "branch_path", "status", "code_changes",
"code_change_count", "latest_code_change", "memory_files_modified",
"memory_last_update", "hours_since_code", "threshold_hours",
"reason", "check_time",
}
assert required_keys.issubset(result.keys())
@patch(MOCK_PATCH_ACTIVITY)
def test_since_timestamp_passed_to_scanner(self, mock_scan):
"""Verify that since_timestamp is forwarded to the activity collector."""
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
since = datetime(2026, 3, 1, 0, 0, 0)
get_branch_status("TEST", "/fake/path/test", since_timestamp=since)
mock_scan.assert_called_once_with("TEST", "/fake/path/test", since)
@patch(MOCK_PATCH_ACTIVITY)
def test_default_threshold_is_two_hours(self, mock_scan):
"""Default threshold_hours should be 2.0."""
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
result = get_branch_status("TEST", "/fake/path/test")
assert result["threshold_hours"] == 2.0
@patch(MOCK_PATCH_ACTIVITY)
def test_custom_threshold_respected(self, mock_scan):
"""Memory 3 hours before code is OK with threshold=4 but RED_FLAG with threshold=2."""
mock_scan.return_value = _make_activity(
code_files=[
{"path": "/fake/app.py", "name": "app.py", "mtime": "2026-03-20T10:00:00"},
],
memory_files=[
{"path": "/fake/.trinity/local.json", "name": "local.json", "mtime": "2026-03-20T07:00:00"},
],
)
# 3 hours before code -- threshold=4 should be OK
result_ok = get_branch_status("TEST", "/fake/path/test", threshold_hours=4.0)
assert result_ok["status"] == STATUS_OK
# Same data -- threshold=2 should be RED_FLAG
result_red = get_branch_status("TEST", "/fake/path/test", threshold_hours=2.0)
assert result_red["status"] == STATUS_RED_FLAG
# =============================================
# detect_red_flags (scan all branches) TESTS
# =============================================
class TestScanAllBranches:
"""Tests for multi-branch scanning and sorting via detect_red_flags."""
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_scans_all_branches(self, mock_paths, mock_scan, mock_log):
"""detect_red_flags scans every branch returned by get_branch_paths."""
mock_paths.return_value = [
{"name": "ALPHA", "path": "/fake/alpha"},
{"name": "BRAVO", "path": "/fake/bravo"},
]
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
results = detect_red_flags(since_timestamp=datetime(2026, 3, 1))
assert len(results) == 2
assert mock_scan.call_count == 2
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_red_flag_sorted_first(self, mock_paths, mock_scan, mock_log):
"""RED_FLAG branches appear before OK and NO_ACTIVITY branches."""
mock_paths.return_value = [
{"name": "OK_BRANCH", "path": "/fake/ok"},
{"name": "BAD_BRANCH", "path": "/fake/bad"},
{"name": "IDLE_BRANCH", "path": "/fake/idle"},
]
def side_effect(name, path, since):
if name == "BAD_BRANCH":
return _make_activity(
branch_name="BAD_BRANCH",
code_files=[{"path": "/f.py", "name": "f.py", "mtime": "2026-03-20T10:00:00"}],
memory_files=[],
)
if name == "OK_BRANCH":
return _make_activity(
branch_name="OK_BRANCH",
code_files=[{"path": "/f.py", "name": "f.py", "mtime": "2026-03-20T10:00:00"}],
memory_files=[{"path": "/m.json", "name": "local.json", "mtime": "2026-03-20T12:00:00"}],
)
return _make_activity(branch_name="IDLE_BRANCH", code_files=[], memory_files=[])
mock_scan.side_effect = side_effect
results = detect_red_flags(since_timestamp=datetime(2026, 3, 1))
assert results[0]["status"] == STATUS_RED_FLAG
assert results[0]["branch_name"] == "BAD_BRANCH"
# OK comes before NO_ACTIVITY in sort order
statuses = [r["status"] for r in results]
assert statuses.index(STATUS_RED_FLAG) < statuses.index(STATUS_OK)
assert statuses.index(STATUS_OK) < statuses.index(STATUS_NO_ACTIVITY)
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_empty_branch_list(self, mock_paths, mock_scan, mock_log):
"""No branches registered -> empty results list."""
mock_paths.return_value = []
results = detect_red_flags(since_timestamp=datetime(2026, 3, 1))
assert results == []
mock_scan.assert_not_called()
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_skips_branches_missing_name_or_path(self, mock_paths, mock_scan, mock_log):
"""Branches with empty name or path are skipped."""
mock_paths.return_value = [
{"name": "", "path": "/fake/noname"},
{"name": "VALID", "path": ""},
{"name": "GOOD", "path": "/fake/good"},
]
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
results = detect_red_flags(since_timestamp=datetime(2026, 3, 1))
assert len(results) == 1
assert results[0]["branch_name"] == "GOOD"
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_alphabetical_sort_within_same_status(self, mock_paths, mock_scan, mock_log):
"""Branches with the same status are sorted alphabetically by name."""
mock_paths.return_value = [
{"name": "ZULU", "path": "/fake/zulu"},
{"name": "ALPHA", "path": "/fake/alpha"},
{"name": "MIKE", "path": "/fake/mike"},
]
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
results = detect_red_flags(since_timestamp=datetime(2026, 3, 1))
names = [r["branch_name"] for r in results]
assert names == ["ALPHA", "MIKE", "ZULU"]
# =============================================
# get_red_flag_summary TESTS
# =============================================
class TestGetRedFlagSummary:
"""Tests for the get_red_flag_summary aggregation function."""
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_mixed_status_counts(self, mock_paths, mock_scan, mock_log):
"""Verify counts with a mix of RED_FLAG, OK, and NO_ACTIVITY branches."""
mock_paths.return_value = [
{"name": "RED_ONE", "path": "/fake/red1"},
{"name": "OK_ONE", "path": "/fake/ok1"},
{"name": "IDLE_ONE", "path": "/fake/idle1"},
{"name": "RED_TWO", "path": "/fake/red2"},
]
def side_effect(name, path, since):
if name.startswith("RED"):
return _make_activity(
branch_name=name,
code_files=[{"path": "/f.py", "name": "f.py", "mtime": "2026-03-20T10:00:00"}],
memory_files=[],
)
if name.startswith("OK"):
return _make_activity(
branch_name=name,
code_files=[{"path": "/f.py", "name": "f.py", "mtime": "2026-03-20T10:00:00"}],
memory_files=[{"path": "/m.json", "name": "local.json", "mtime": "2026-03-20T12:00:00"}],
)
return _make_activity(branch_name=name, code_files=[], memory_files=[])
mock_scan.side_effect = side_effect
summary = get_red_flag_summary(since_timestamp=datetime(2026, 3, 1))
assert summary["total_branches"] == 4
assert summary["red_flags"] == 2
assert summary["ok"] == 1
assert summary["no_activity"] == 1
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_empty_branch_list_zero_counts(self, mock_paths, mock_scan, mock_log):
"""Empty branch list yields zero counts across the board."""
mock_paths.return_value = []
summary = get_red_flag_summary(since_timestamp=datetime(2026, 3, 1))
assert summary["total_branches"] == 0
assert summary["red_flags"] == 0
assert summary["ok"] == 0
assert summary["no_activity"] == 0
assert summary["violations"] == []
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_violations_only_contains_red_flag(self, mock_paths, mock_scan, mock_log):
"""The violations list should only contain RED_FLAG branches."""
mock_paths.return_value = [
{"name": "BAD", "path": "/fake/bad"},
{"name": "GOOD", "path": "/fake/good"},
{"name": "IDLE", "path": "/fake/idle"},
]
def side_effect(name, path, since):
if name == "BAD":
return _make_activity(
branch_name="BAD",
code_files=[{"path": "/f.py", "name": "f.py", "mtime": "2026-03-20T10:00:00"}],
memory_files=[],
)
if name == "GOOD":
return _make_activity(
branch_name="GOOD",
code_files=[{"path": "/f.py", "name": "f.py", "mtime": "2026-03-20T10:00:00"}],
memory_files=[{"path": "/m.json", "name": "local.json", "mtime": "2026-03-20T12:00:00"}],
)
return _make_activity(branch_name="IDLE", code_files=[], memory_files=[])
mock_scan.side_effect = side_effect
summary = get_red_flag_summary(since_timestamp=datetime(2026, 3, 1))
assert len(summary["violations"]) == 1
assert all(v["status"] == STATUS_RED_FLAG for v in summary["violations"])
assert summary["violations"][0]["branch_name"] == "BAD"
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_summary_has_expected_keys(self, mock_paths, mock_scan, mock_log):
"""Summary dict contains all documented keys."""
mock_paths.return_value = []
summary = get_red_flag_summary(since_timestamp=datetime(2026, 3, 1))
expected_keys = {
"total_branches", "red_flags", "ok", "no_activity",
"violations", "scan_time", "threshold_hours",
"time_window_hours", "errors", "all_branches",
}
assert expected_keys.issubset(set(summary.keys()))
@patch(MOCK_PATCH_JSON_LOG)
@patch(MOCK_PATCH_ACTIVITY)
@patch(MOCK_PATCH_BRANCHES)
def test_all_branches_matches_total(self, mock_paths, mock_scan, mock_log):
"""The all_branches list length should match total_branches count."""
mock_paths.return_value = [
{"name": "A", "path": "/fake/a"},
{"name": "B", "path": "/fake/b"},
{"name": "C", "path": "/fake/c"},
]
mock_scan.return_value = _make_activity(code_files=[], memory_files=[])
summary = get_red_flag_summary(since_timestamp=datetime(2026, 3, 1))
assert len(summary["all_branches"]) == summary["total_branches"]
assert summary["total_branches"] == 3
@@ -0,0 +1,352 @@
# =================== AIPass ====================
# Name: test_schedule_module.py
# Description: Tests for the schedule CLI module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for the schedule CLI module (apps/modules/schedule.py)."""
from unittest.mock import patch, MagicMock
MODULE = "aipass.daemon.apps.modules.schedule"
# =============================================
# handle_command -- routing basics
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestHandleCommandRouting:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _log, _err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
assert handle_command("not_schedule", []) is False
def test_no_args_shows_introspection(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", [])
assert result is True
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("schedule Module" in c for c in calls)
def test_help_flag(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["--help"])
assert result is True
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("USAGE" in c for c in calls)
def test_unknown_subcommand(self, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["foobar"])
assert result is False
mock_err.assert_called()
# =============================================
# handle_command -- list subcommand
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestListSubcommand:
"""Tests for 'schedule list' subcommand."""
@patch(f"{MODULE}.load_tasks", return_value=[])
def test_list_success(self, mock_load, _log, _err, mock_con, mock_jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["list"])
assert result is True
mock_load.assert_called_once()
@patch(f"{MODULE}.load_tasks", side_effect=RuntimeError("disk error"))
def test_list_exception(self, _load, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["list"])
assert result is False
mock_err.assert_called()
# =============================================
# handle_command -- delete subcommand
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestDeleteSubcommand:
"""Tests for 'schedule delete' subcommand."""
def test_delete_no_args_shows_error(self, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["delete"])
assert result is False
mock_err.assert_called()
@patch(f"{MODULE}.delete_task", return_value=True)
def test_delete_success(self, mock_del, _log, _err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["delete", "abc123"])
assert result is True
mock_del.assert_called_once_with("abc123")
@patch(f"{MODULE}.delete_task", return_value=False)
def test_delete_not_found(self, mock_del, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["delete", "abc123"])
assert result is False
mock_err.assert_called()
# =============================================
# handle_command -- run-due subcommand
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestRunDueSubcommand:
"""Tests for 'schedule run-due' subcommand."""
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 0,
"due": 0,
"success": 0,
"failed": 0,
"processed_tasks": [],
},
)
@patch(f"{MODULE}.FILELOCK_AVAILABLE", False)
def test_run_due_without_lock(self, mock_batch, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["run-due"])
assert result is True
mock_batch.assert_called_once()
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 1,
"due": 2,
"success": 1,
"failed": 1,
"processed_tasks": [
{"id": "a1", "recipient": "@flow", "task": "Check plan", "status": "sent"},
{"id": "a2", "recipient": "@seedgo", "task": "Audit", "status": "failed"},
],
},
)
@patch(f"{MODULE}.FILELOCK_AVAILABLE", False)
def test_run_due_processes_tasks(self, mock_batch, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["run-due"])
assert result is True
mock_batch.assert_called_once()
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "1 sent" in calls
assert "1 failed" in calls
# =============================================
# _handle_create
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestHandleCreate:
"""Tests for _handle_create."""
@patch(f"{MODULE}.create_task", return_value={"id": "task-001"})
@patch(f"{MODULE}.parse_due_date", return_value="2026-04-10")
def test_create_valid(self, _due, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.schedule import _handle_create
result = _handle_create(["Follow up", "--due", "7d", "--to", "@flow"])
assert result is True
mock_create.assert_called_once()
def test_create_missing_task(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _handle_create
result = _handle_create(["--due", "7d", "--to", "@flow"])
assert result is False
mock_err.assert_called()
@patch(f"{MODULE}.parse_due_date", return_value=None)
def test_create_invalid_due(self, _due, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _handle_create
result = _handle_create(["Task text", "--due", "xyz", "--to", "@flow"])
assert result is False
mock_err.assert_called()
# =============================================
# _process_due_tasks
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestProcessDueTasks:
"""Tests for _process_due_tasks."""
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 0,
"due": 0,
"success": 0,
"failed": 0,
"processed_tasks": [],
},
)
def test_no_tasks_due(self, mock_batch, _log, _err, mock_con):
from aipass.daemon.apps.modules.schedule import _process_due_tasks
result = _process_due_tasks()
assert result is True
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "No tasks due" in calls
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 0,
"due": 2,
"success": 1,
"failed": 1,
"processed_tasks": [
{"id": "t1", "recipient": "@flow", "task": "Check", "status": "sent"},
{"id": "t2", "recipient": "@seedgo", "task": "Audit", "status": "failed"},
],
},
)
def test_mix_sent_failed(self, mock_batch, _log, _err, mock_con):
from aipass.daemon.apps.modules.schedule import _process_due_tasks
result = _process_due_tasks()
assert result is True
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "1 sent" in calls
assert "1 failed" in calls
# =============================================
# _display_task_result
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestDisplayTaskResult:
"""Tests for _display_task_result per-status output."""
def test_status_sent(self, mock_log, _err, mock_con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t1",
"recipient": "@flow",
"task": "Check plan",
"status": "sent",
}
)
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "OK" in calls or "Sent" in calls or "@flow" in calls
def test_status_skipped(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t2",
"recipient": "@seedgo",
"task": "Audit",
"status": "skipped",
}
)
mock_err.assert_called()
def test_status_failed(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t3",
"recipient": "@daemon",
"task": "Heartbeat",
"status": "failed",
}
)
mock_err.assert_called()
def test_status_error(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t4",
"recipient": "@drone",
"task": "Ping",
"status": "error",
"error": "timeout",
}
)
mock_err.assert_called()
# =============================================
# _send_email_via_drone
# =============================================
@patch(f"{MODULE}.logger")
class TestSendEmailViaDrone:
"""Tests for _send_email_via_drone subprocess wrapper."""
@patch("subprocess.run")
def test_success(self, mock_run, _log):
from aipass.daemon.apps.modules.schedule import _send_email_via_drone
mock_run.return_value = MagicMock(returncode=0)
assert _send_email_via_drone("@flow", "subj", "body") is True
mock_run.assert_called_once()
@patch("subprocess.run", side_effect=OSError("no drone"))
def test_failure(self, _run, _log):
from aipass.daemon.apps.modules.schedule import _send_email_via_drone
assert _send_email_via_drone("@flow", "subj", "body") is False
@@ -0,0 +1,479 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_scheduler_cron.py - Scheduler Cron Tests
# Date: 2026-04-02
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-04-02): Initial creation - scheduler_cron dispatch path tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Full mock isolation (no real subprocesses or locks)
# =============================================
"""Tests for scheduler_cron dispatch paths."""
import subprocess
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
MODULE = "aipass.daemon.apps.scheduler_cron"
# =============================================
# FIXTURES
# =============================================
def _make_task(
task_id: str = "abc12345-6789",
recipient: str = "@devpulse",
task: str = "Run morning briefing",
message: str = "Details here",
) -> dict:
"""Build a minimal task dict for testing."""
return {
"id": task_id,
"recipient": recipient,
"task": task,
"message": message,
}
@pytest.fixture(autouse=True)
def _silence_logging():
"""Suppress logger and console output for all tests."""
with (
patch(f"{MODULE}.logger"),
patch(f"{MODULE}.console"),
patch(f"{MODULE}.log"),
):
yield
# =============================================
# _send_email_via_drone
# =============================================
class TestSendEmailViaDrone:
"""Tests for _send_email_via_drone subprocess wrapper."""
def test_success(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
mock_result = MagicMock(returncode=0)
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
result = _send_email_via_drone("@devpulse", "Subject", "Body")
assert result is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd[:3] == ["drone", "@ai_mail", "send"]
assert "--dispatch" in cmd
def test_no_auto_execute(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
mock_result = MagicMock(returncode=0)
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
_send_email_via_drone("@devpulse", "Subj", "Msg", auto_execute=False)
cmd = mock_run.call_args[0][0]
assert "--dispatch" not in cmd
def test_nonzero_returncode(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
mock_result = MagicMock(returncode=1)
with patch(f"{MODULE}.subprocess.run", return_value=mock_result):
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
assert result is False
def test_subprocess_error(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
with patch(f"{MODULE}.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="drone", timeout=15)):
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
assert result is False
def test_os_error(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
with patch(f"{MODULE}.subprocess.run", side_effect=OSError("drone not found")):
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
assert result is False
# =============================================
# _next_cron_run
# =============================================
class TestNextCronRun:
"""Tests for next cron run time calculation."""
def test_before_half_hour(self):
from aipass.daemon.apps.scheduler_cron import _next_cron_run
fake_now = datetime(2026, 4, 2, 10, 15, 0)
with patch(f"{MODULE}.datetime") as mock_dt:
mock_dt.now.return_value = fake_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
result = _next_cron_run()
assert result == "10:30"
def test_after_half_hour(self):
from aipass.daemon.apps.scheduler_cron import _next_cron_run
fake_now = datetime(2026, 4, 2, 10, 45, 0)
with patch(f"{MODULE}.datetime") as mock_dt:
mock_dt.now.return_value = fake_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
result = _next_cron_run()
assert result == "11:00"
def test_before_midnight_rollover(self):
from aipass.daemon.apps.scheduler_cron import _next_cron_run
fake_now = datetime(2026, 4, 2, 23, 45, 0)
with patch(f"{MODULE}.datetime") as mock_dt:
mock_dt.now.return_value = fake_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
result = _next_cron_run()
assert result == "00:00"
# =============================================
# _process_single_task
# =============================================
class TestProcessSingleTask:
"""Tests for the single-task dispatch function."""
def test_success_path(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching") as mock_dispatch,
patch(f"{MODULE}.send_email_direct", return_value=True) as mock_send,
patch(f"{MODULE}.mark_completed") as mock_complete,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
_process_single_task(task, results)
mock_dispatch.assert_called_once_with(task["id"])
mock_send.assert_called_once()
mock_complete.assert_called_once_with(task["id"])
assert results["success"] == 1
assert results["failed"] == 0
def test_mark_dispatching_failure(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching", side_effect=RuntimeError("lock error")),
patch(f"{MODULE}.send_email_direct") as mock_send,
):
_process_single_task(task, results)
mock_send.assert_not_called()
assert results["failed"] == 1
assert len(results["errors"]) == 1
def test_email_unavailable(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.AI_MAIL_AVAILABLE", False),
patch(f"{MODULE}.mark_pending") as mock_pending,
):
_process_single_task(task, results)
mock_pending.assert_called_once_with(task["id"])
assert results["failed"] == 1
def test_email_send_returns_false(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", return_value=False),
patch(f"{MODULE}.mark_pending") as mock_pending,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
_process_single_task(task, results)
mock_pending.assert_called_once_with(task["id"])
assert results["failed"] == 1
assert results["success"] == 0
def test_email_exception_resets_to_pending(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", side_effect=ConnectionError("timeout")),
patch(f"{MODULE}.mark_pending") as mock_pending,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
_process_single_task(task, results)
mock_pending.assert_called_once_with(task["id"])
assert results["failed"] == 1
# =============================================
# process_due_tasks
# =============================================
class TestProcessDueTasks:
"""Tests for the top-level due-task processor."""
def test_no_tasks_due(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", return_value=[]),
):
results = process_due_tasks()
assert results["due"] == 0
assert results["success"] == 0
def test_task_registry_unavailable(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", False):
results = process_due_tasks()
assert results["due"] == 0
assert results["success"] == 0
def test_stale_dispatch_recovery(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=3) as mock_recover,
patch(f"{MODULE}.get_due_tasks", return_value=[]),
):
results = process_due_tasks()
mock_recover.assert_called_once_with(max_age_minutes=5)
assert results["recovered"] == 3
def test_stale_recovery_exception(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", side_effect=RuntimeError("fs error")),
patch(f"{MODULE}.get_due_tasks", return_value=[]),
):
results = process_due_tasks()
assert len(results["errors"]) == 1
assert "Stale recovery" in results["errors"][0]
def test_get_due_tasks_exception(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", side_effect=RuntimeError("corrupt JSON")),
):
results = process_due_tasks()
assert "Load tasks" in results["errors"][0]
@patch(f"{MODULE}.time.sleep")
def test_successful_send(self, _mock_sleep):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
task = _make_task()
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", return_value=True),
patch(f"{MODULE}.mark_completed"),
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
results = process_due_tasks()
assert results["due"] == 1
assert results["success"] == 1
assert results["failed"] == 0
@patch(f"{MODULE}.time.sleep")
def test_send_failure_marks_pending(self, _mock_sleep):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
task = _make_task()
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", return_value=False),
patch(f"{MODULE}.mark_pending") as mock_pending,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
results = process_due_tasks()
mock_pending.assert_called_once()
assert results["failed"] == 1
# =============================================
# _run_locked
# =============================================
class TestRunLocked:
"""Tests for the locked orchestration function."""
def test_success_no_errors(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
action_results = {
"total": 0, "enabled": 0, "executed": 0, "failed": 0,
"errors": [], "executed_actions": [], "skipped_actions": [],
}
with (
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
patch(f"{MODULE}.process_actions", return_value=action_results),
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
):
code = _run_locked()
assert code == 0
def test_returns_1_on_task_failures(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
task_results = {"due": 1, "success": 0, "failed": 1, "recovered": 0, "errors": ["fail"]}
action_results = {
"total": 0, "enabled": 0, "executed": 0, "failed": 0,
"errors": [], "executed_actions": [], "skipped_actions": [],
}
with (
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
patch(f"{MODULE}.process_actions", return_value=action_results),
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
):
code = _run_locked()
assert code == 1
def test_process_due_tasks_unhandled_exception(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
with patch(f"{MODULE}.process_due_tasks", side_effect=RuntimeError("boom")):
code = _run_locked()
assert code == 1
def test_process_actions_exception_handled(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
with (
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
patch(f"{MODULE}.process_actions", side_effect=RuntimeError("action boom")),
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
):
code = _run_locked()
# The action error is caught but appended to errors, triggering exit 1
assert code == 1
# =============================================
# main
# =============================================
class TestMain:
"""Tests for the main entry point."""
def test_no_args_introspection(self):
from aipass.daemon.apps.scheduler_cron import main
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py"]),
patch(f"{MODULE}.print_introspection") as mock_intro,
):
code = main()
mock_intro.assert_called_once()
assert code == 0
def test_help_flag(self):
from aipass.daemon.apps.scheduler_cron import main
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "--help"]),
patch(f"{MODULE}.print_help") as mock_help,
):
with pytest.raises(SystemExit) as exc_info:
main()
mock_help.assert_called_once()
assert exc_info.value.code == 0
def test_lock_acquisition_failure(self, tmp_path):
from aipass.daemon.apps.scheduler_cron import main
lock_file = tmp_path / "schedule.lock"
mock_fd = MagicMock()
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
patch(f"{MODULE}.json_handler"),
patch(f"{MODULE}.LOCK_FILE", lock_file),
patch("builtins.open", return_value=mock_fd),
patch(f"{MODULE}.fcntl.flock", side_effect=OSError("locked")),
):
code = main()
assert code == 0 # graceful skip when another instance is running
mock_fd.close.assert_called()
def test_lock_acquired_runs_locked(self, tmp_path):
from aipass.daemon.apps.scheduler_cron import main
lock_file = tmp_path / "schedule.lock"
mock_fd = MagicMock()
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
patch(f"{MODULE}.json_handler"),
patch(f"{MODULE}.LOCK_FILE", lock_file),
patch("builtins.open", return_value=mock_fd),
patch(f"{MODULE}.fcntl.flock"),
patch(f"{MODULE}._run_locked", return_value=0) as mock_run,
):
code = main()
mock_run.assert_called_once()
assert code == 0
@@ -0,0 +1,114 @@
# =================== AIPass ====================
# Name: test_scheduler_ops.py
# Description: Tests for the scheduler_ops facade module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for the scheduler_ops facade module (apps/modules/scheduler_ops.py)."""
from unittest.mock import patch
MODULE = "aipass.daemon.apps.modules.scheduler_ops"
# =============================================
# handle_command — routing
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.logger")
class TestHandleCommand:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _log, _con, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("not-scheduler-ops", []) is False
def test_no_args_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
result = handle_command("scheduler-ops", [])
assert result is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_help_flag_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["--help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_h_flag_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["-h"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_help_word_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_status_arg_shows_registry_info(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["status"]) is True
mock_jh.log_operation.assert_called_once_with("scheduler_ops_status")
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Scheduler Ops" in c for c in calls)
def test_status_prints_task_registry_availability(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
handle_command("scheduler-ops", ["status"])
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Task registry" in c for c in calls)
def test_status_prints_action_registry_availability(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
handle_command("scheduler-ops", ["status"])
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Action registry" in c for c in calls)
# =============================================
# Module-level availability flags
# =============================================
class TestRegistryAvailability:
"""Verify that registry imports succeed in the test environment."""
def test_task_registry_available(self):
from aipass.daemon.apps.modules.scheduler_ops import TASK_REGISTRY_AVAILABLE
assert TASK_REGISTRY_AVAILABLE is True
def test_action_registry_available(self):
from aipass.daemon.apps.modules.scheduler_ops import ACTION_REGISTRY_AVAILABLE
assert ACTION_REGISTRY_AVAILABLE is True
# =============================================
# print_introspection
# =============================================
@patch(f"{MODULE}.console")
class TestPrintIntrospection:
"""Tests for print_introspection output."""
def test_prints_module_header(self, mock_console):
from aipass.daemon.apps.modules.scheduler_ops import print_introspection
print_introspection()
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
@@ -0,0 +1,532 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_task_registry.py - Task Registry Tests
# Date: 2026-03-24
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-24): Initial creation - task_registry handler tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Temp dir isolation (no writes to real registry)
# =============================================
"""Tests for the scheduled task registry handler."""
import json
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
from aipass.daemon.apps.handlers.schedule import task_registry as _mod
parse_due_date = _mod.parse_due_date
create_task = _mod.create_task
load_tasks = _mod.load_tasks
save_tasks = _mod.save_tasks
get_due_tasks = _mod.get_due_tasks
mark_dispatching = _mod.mark_dispatching
mark_completed = _mod.mark_completed
mark_pending = _mod.mark_pending
recover_stale_dispatches = _mod.recover_stale_dispatches
delete_task = _mod.delete_task
get_task_by_id = _mod.get_task_by_id
get_pending_tasks = _mod.get_pending_tasks
ensure_lock_dir = _mod.ensure_lock_dir
@pytest.fixture(autouse=True)
def isolate_registry(tmp_path):
"""Redirect SCHEDULE_JSON_PATH to a temp dir for every test."""
test_file = tmp_path / "schedule.json"
original = _mod.SCHEDULE_JSON_PATH
_mod.SCHEDULE_JSON_PATH = test_file
yield test_file
_mod.SCHEDULE_JSON_PATH = original
# =============================================
# DATE PARSING TESTS
# =============================================
class TestParseDueDate:
def test_days_format(self):
"""'7d' should resolve to 7 days from today."""
result = parse_due_date("7d")
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
assert result == expected
def test_days_format_single_digit(self):
"""'1d' should resolve to tomorrow."""
result = parse_due_date("1d")
expected = (datetime.now().date() + timedelta(days=1)).isoformat()
assert result == expected
def test_weeks_format(self):
"""'1w' should resolve to 1 week from today."""
result = parse_due_date("1w")
expected = (datetime.now().date() + timedelta(weeks=1)).isoformat()
assert result == expected
def test_weeks_format_multiple(self):
"""'2w' should resolve to 2 weeks from today."""
result = parse_due_date("2w")
expected = (datetime.now().date() + timedelta(weeks=2)).isoformat()
assert result == expected
def test_iso_date_format(self):
"""'2026-06-15' should pass through as-is."""
result = parse_due_date("2026-06-15")
assert result == "2026-06-15"
def test_whitespace_stripped(self):
"""Leading/trailing whitespace should be stripped."""
result = parse_due_date(" 7d ")
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
assert result == expected
def test_case_insensitive_days(self):
"""'7D' should work the same as '7d'."""
result = parse_due_date("7D")
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
assert result == expected
def test_case_insensitive_weeks(self):
"""'2W' should work the same as '2w'."""
result = parse_due_date("2W")
expected = (datetime.now().date() + timedelta(weeks=2)).isoformat()
assert result == expected
def test_invalid_format_raises(self):
"""Unsupported format should raise ValueError."""
with pytest.raises(ValueError, match="Invalid date format"):
parse_due_date("next tuesday")
def test_invalid_iso_date_raises(self):
"""Invalid calendar date in ISO format should raise ValueError."""
with pytest.raises(ValueError, match="Invalid date"):
parse_due_date("2026-02-30")
def test_empty_string_raises(self):
"""Empty string should raise ValueError."""
with pytest.raises(ValueError, match="Invalid date format"):
parse_due_date("")
def test_zero_days(self):
"""'0d' should resolve to today."""
result = parse_due_date("0d")
expected = datetime.now().date().isoformat()
assert result == expected
# =============================================
# LOAD / SAVE TESTS
# =============================================
class TestLoadSave:
def test_load_creates_file_if_missing(self, isolate_registry):
"""load_tasks should create schedule.json if it does not exist."""
assert not isolate_registry.exists()
tasks = load_tasks()
assert tasks == []
assert isolate_registry.exists()
def test_load_returns_empty_on_fresh_file(self):
"""Fresh schedule.json should have no tasks."""
tasks = load_tasks()
assert tasks == []
def test_save_and_load_roundtrip(self, isolate_registry):
"""save_tasks then load_tasks should return the same data."""
sample = [{"id": "abc123", "task": "test", "status": "pending"}]
assert save_tasks(sample) is True
loaded = load_tasks()
assert len(loaded) == 1
assert loaded[0]["id"] == "abc123"
def test_save_overwrites_existing(self, isolate_registry):
"""Saving new tasks should fully replace existing data."""
save_tasks([{"id": "first", "status": "pending"}])
save_tasks([{"id": "second", "status": "pending"}])
loaded = load_tasks()
assert len(loaded) == 1
assert loaded[0]["id"] == "second"
def test_load_handles_corrupt_json(self, isolate_registry):
"""Corrupt JSON should return empty list, not crash."""
isolate_registry.parent.mkdir(parents=True, exist_ok=True)
isolate_registry.write_text("{invalid json", encoding="utf-8")
tasks = load_tasks()
assert tasks == []
# =============================================
# CREATE TASK TESTS
# =============================================
class TestCreateTask:
@patch.object(_mod.json_handler, "log_operation")
def test_create_basic(self, mock_log):
"""Create a task and verify all fields."""
task = create_task(
task="Check backup health",
due_date="7d",
recipient="@devpulse",
message="Verify backup systems",
)
assert task["task"] == "Check backup health"
assert task["recipient"] == "@devpulse"
assert task["message"] == "Verify backup systems"
assert task["status"] == "pending"
assert len(task["id"]) == 16
assert task["id"].isalnum()
assert task["created"] == datetime.now().date().isoformat()
mock_log.assert_called_once_with("task_created")
@patch.object(_mod.json_handler, "log_operation")
def test_create_persists_to_json(self, mock_log, isolate_registry):
"""Created task should be saved to the JSON file."""
create_task(
task="persisted task",
due_date="1d",
recipient="@seedgo",
message="msg",
)
raw = json.loads(isolate_registry.read_text(encoding="utf-8"))
assert len(raw["tasks"]) == 1
assert raw["tasks"][0]["task"] == "persisted task"
@patch.object(_mod.json_handler, "log_operation")
def test_create_multiple_tasks(self, mock_log):
"""Multiple tasks should accumulate in the registry."""
create_task(task="t1", due_date="1d", recipient="@a", message="m1")
create_task(task="t2", due_date="2d", recipient="@b", message="m2")
tasks = load_tasks()
assert len(tasks) == 2
assert tasks[0]["task"] == "t1"
assert tasks[1]["task"] == "t2"
def test_create_invalid_date_raises(self):
"""create_task should propagate ValueError from bad due_date."""
with pytest.raises(ValueError):
create_task(task="bad", due_date="xyz", recipient="@a", message="m")
# =============================================
# DUE TASKS TESTS
# =============================================
class TestDueTasks:
def test_overdue_task_returned(self, isolate_registry):
"""A pending task with a past due_date should be returned."""
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
save_tasks([{
"id": "past01",
"due_date": yesterday,
"status": "pending",
"task": "overdue",
}])
due = get_due_tasks()
assert len(due) == 1
assert due[0]["id"] == "past01"
def test_today_task_returned(self, isolate_registry):
"""A pending task due today should be returned."""
today = datetime.now().date().isoformat()
save_tasks([{
"id": "today01",
"due_date": today,
"status": "pending",
"task": "due today",
}])
due = get_due_tasks()
assert len(due) == 1
assert due[0]["id"] == "today01"
def test_future_task_not_returned(self, isolate_registry):
"""A pending task with a future due_date should not be returned."""
future = (datetime.now().date() + timedelta(days=30)).isoformat()
save_tasks([{
"id": "future01",
"due_date": future,
"status": "pending",
"task": "future task",
}])
due = get_due_tasks()
assert len(due) == 0
def test_dispatching_task_excluded(self, isolate_registry):
"""Tasks with status 'dispatching' should not be returned."""
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
save_tasks([{
"id": "disp01",
"due_date": yesterday,
"status": "dispatching",
"task": "already dispatching",
}])
due = get_due_tasks()
assert len(due) == 0
def test_completed_task_excluded(self, isolate_registry):
"""Tasks with status 'completed' should not be returned."""
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
save_tasks([{
"id": "done01",
"due_date": yesterday,
"status": "completed",
"task": "done",
}])
due = get_due_tasks()
assert len(due) == 0
def test_empty_registry_returns_empty(self):
"""Empty registry should return empty list."""
due = get_due_tasks()
assert due == []
# =============================================
# STATUS TRANSITION TESTS
# =============================================
class TestStatusTransitions:
def _seed_task(self, task_id: str = "abc12345abcd1234", status: str = "pending"):
"""Helper to seed a single task."""
save_tasks([{
"id": task_id,
"task": "test",
"status": status,
"due_date": "2026-01-01",
}])
return task_id
def test_mark_dispatching_success(self):
"""mark_dispatching should set status and dispatch_started."""
tid = self._seed_task()
assert mark_dispatching(tid) is True
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "dispatching"
assert "dispatch_started" in task
def test_mark_dispatching_missing(self):
"""mark_dispatching returns False for nonexistent ID."""
assert mark_dispatching("nonexistent_id__") is False
def test_mark_completed_success(self):
"""mark_completed should set status and completed_date."""
tid = self._seed_task()
assert mark_completed(tid) is True
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "completed"
assert task["completed_date"] == datetime.now().date().isoformat()
def test_mark_completed_missing(self):
"""mark_completed returns False for nonexistent ID."""
assert mark_completed("nonexistent_id__") is False
def test_mark_pending_success(self):
"""mark_pending should reset status and remove dispatch_started."""
tid = self._seed_task(status="dispatching")
# Add dispatch_started to simulate real scenario
tasks = load_tasks()
tasks[0]["dispatch_started"] = datetime.now().isoformat()
save_tasks(tasks)
assert mark_pending(tid) is True
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "pending"
assert "dispatch_started" not in task
def test_mark_pending_missing(self):
"""mark_pending returns False for nonexistent ID."""
assert mark_pending("nonexistent_id__") is False
def test_full_lifecycle(self):
"""pending -> dispatching -> completed lifecycle."""
tid = self._seed_task()
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "pending"
mark_dispatching(tid)
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "dispatching"
mark_completed(tid)
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "completed"
# =============================================
# RECOVER STALE DISPATCHES TESTS
# =============================================
class TestRecoverStale:
def test_recovers_stale_task(self, isolate_registry):
"""Task stuck in dispatching beyond max_age should be reset."""
stale_time = (datetime.now() - timedelta(minutes=10)).isoformat()
save_tasks([{
"id": "stale01",
"task": "stale dispatch",
"status": "dispatching",
"dispatch_started": stale_time,
"due_date": "2026-01-01",
}])
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 1
task = get_task_by_id("stale01")
assert task is not None
assert task["status"] == "pending"
assert "dispatch_started" not in task
def test_does_not_recover_recent_dispatch(self, isolate_registry):
"""Task dispatching within max_age should not be recovered."""
recent_time = (datetime.now() - timedelta(minutes=1)).isoformat()
save_tasks([{
"id": "recent01",
"task": "recent dispatch",
"status": "dispatching",
"dispatch_started": recent_time,
"due_date": "2026-01-01",
}])
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 0
task = get_task_by_id("recent01")
assert task is not None
assert task["status"] == "dispatching"
def test_recovers_invalid_timestamp(self, isolate_registry):
"""Task with unparseable dispatch_started should be recovered."""
save_tasks([{
"id": "bad_ts01",
"task": "bad timestamp",
"status": "dispatching",
"dispatch_started": "not-a-date",
"due_date": "2026-01-01",
}])
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 1
task = get_task_by_id("bad_ts01")
assert task is not None
assert task["status"] == "pending"
def test_pending_tasks_untouched(self, isolate_registry):
"""Pending tasks should not be affected by recovery."""
save_tasks([{
"id": "ok01",
"task": "normal pending",
"status": "pending",
"due_date": "2026-01-01",
}])
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 0
task = get_task_by_id("ok01")
assert task is not None
assert task["status"] == "pending"
def test_empty_registry_returns_zero(self):
"""Recovery on empty registry should return 0."""
assert recover_stale_dispatches() == 0
# =============================================
# DELETE TASK TESTS
# =============================================
class TestDeleteTask:
def test_delete_existing(self, isolate_registry):
"""Deleting an existing task returns True and removes it."""
save_tasks([{"id": "del01", "task": "to delete", "status": "pending"}])
assert delete_task("del01") is True
assert get_task_by_id("del01") is None
assert load_tasks() == []
def test_delete_missing(self):
"""Deleting a nonexistent task returns False."""
assert delete_task("nonexistent_id__") is False
def test_delete_preserves_other_tasks(self, isolate_registry):
"""Deleting one task should leave others intact."""
save_tasks([
{"id": "keep01", "task": "keep this", "status": "pending"},
{"id": "del02", "task": "delete this", "status": "pending"},
])
delete_task("del02")
remaining = load_tasks()
assert len(remaining) == 1
assert remaining[0]["id"] == "keep01"
def test_delete_from_empty_registry(self):
"""Delete on empty registry should return False without error."""
assert delete_task("anything") is False
# =============================================
# GET PENDING TASKS TESTS
# =============================================
class TestGetPendingTasks:
"""Tests for get_pending_tasks()."""
def test_returns_only_pending(self, isolate_registry):
"""Only tasks with status 'pending' are returned."""
save_tasks([
{"id": "pend01", "task": "pending one", "status": "pending"},
{"id": "pend02", "task": "pending two", "status": "pending"},
{"id": "done01", "task": "done", "status": "completed"},
])
result = get_pending_tasks()
assert len(result) == 2
assert all(t["status"] == "pending" for t in result)
def test_excludes_dispatching_and_completed(self, isolate_registry):
"""Tasks with dispatching or completed status are excluded."""
save_tasks([
{"id": "disp01", "task": "dispatching", "status": "dispatching"},
{"id": "done01", "task": "completed", "status": "completed"},
{"id": "pend01", "task": "pending", "status": "pending"},
])
result = get_pending_tasks()
assert len(result) == 1
assert result[0]["id"] == "pend01"
def test_empty_registry_returns_empty(self):
"""Empty registry returns empty list."""
result = get_pending_tasks()
assert result == []
# =============================================
# ENSURE LOCK DIR TESTS
# =============================================
class TestEnsureLockDir:
"""Tests for ensure_lock_dir()."""
def test_creates_directory_if_missing(self, isolate_registry):
"""Creates the lock directory when it does not exist."""
lock_dir = isolate_registry.parent
if lock_dir.exists():
import shutil
shutil.rmtree(lock_dir)
assert not lock_dir.exists()
result = ensure_lock_dir()
assert lock_dir.exists()
assert lock_dir.is_dir()
assert result["path"] == str(lock_dir)
def test_returns_dict_with_path_key(self, isolate_registry):
"""Return value is a dict containing the 'path' key."""
result = ensure_lock_dir()
assert isinstance(result, dict)
assert "path" in result
assert isinstance(result["path"], str)
@@ -0,0 +1,143 @@
# =================== AIPass ====================
# Name: test_update_and_errors.py
# Description: Tests for update command and error message formatting
# Version: 1.0.0
# Created: 2026-03-30
# Modified: 2026-03-30
# =============================================
"""
Tests for the update command (no longer a dead end) and error message
formatting (no cascading double-errors).
Covers:
- update: runs digest with no args, help flag works
- actions errors: single error message, no cascade
- branch-health: no-args shows all-branches summary
"""
from unittest.mock import patch, MagicMock
import pytest
from aipass.daemon.apps import daemon as _daemon_mod
from aipass.daemon.apps.modules import update as _update_mod
from aipass.daemon.apps.modules import actions as _actions_mod
from aipass.daemon.apps.modules import activity_report as _activity_mod
@pytest.fixture(autouse=True)
def _mock_log_operations():
"""Prevent json_handler.log_operation from touching real files."""
with (
patch.object(_daemon_mod.json_handler, "log_operation", return_value=True),
patch.object(_update_mod.json_handler, "log_operation", return_value=True),
patch.object(_actions_mod.json_handler, "log_operation", return_value=True),
patch.object(_activity_mod.json_handler, "log_operation", return_value=True),
):
yield
# ============================================================================
# Update command tests
# ============================================================================
class TestUpdateCommand:
"""Tests for the update module — no longer a dead end."""
def test_update_no_args_runs_digest(self) -> None:
"""update with no args should run the digest, not show introspection."""
with patch.object(_update_mod, "load_inbox", return_value={"messages": [], "total_messages": 0}), \
patch.object(_update_mod, "load_local", return_value={}):
result = _update_mod.handle_command("update", [])
assert result is True
def test_update_no_args_calls_load_inbox(self) -> None:
"""update with no args should call load_inbox (proving it runs the digest)."""
mock_inbox = MagicMock(return_value={"messages": [], "total_messages": 0})
with patch.object(_update_mod, "load_inbox", mock_inbox), \
patch.object(_update_mod, "load_local", return_value={}):
_update_mod.handle_command("update", [])
mock_inbox.assert_called_once()
def test_update_help_flag(self) -> None:
"""update --help should show help and return True."""
result = _update_mod.handle_command("update", ["--help"])
assert result is True
def test_update_wrong_command(self) -> None:
"""update module should not handle other commands."""
result = _update_mod.handle_command("schedule", [])
assert result is False
def test_update_error_returns_true(self) -> None:
"""update should return True even on error (command was handled)."""
with patch.object(_update_mod, "load_inbox", side_effect=Exception("test error")):
result = _update_mod.handle_command("update", [])
assert result is True
# ============================================================================
# Error cascade tests — single error message, no double-error
# ============================================================================
class TestErrorCascade:
"""Tests that error paths return True (command handled) to prevent cascade."""
def test_actions_unknown_subcommand_returns_true(self) -> None:
"""Unknown subcommand should return True (error displayed, not cascaded)."""
result = _actions_mod.handle_command("actions", ["nonexistent_xyz"])
assert result is True, "Unknown subcommand must return True to prevent cascade"
def test_actions_invalid_id_returns_true(self) -> None:
"""Invalid action ID should return True (error displayed, not cascaded)."""
result = _actions_mod.handle_command("actions", ["9999", "info"])
assert result is True, "Invalid ID must return True to prevent cascade"
def test_actions_delete_no_id_returns_true(self) -> None:
"""actions delete with no ID should return True (error displayed)."""
result = _actions_mod.handle_command("actions", ["delete"])
assert result is True
def test_actions_set_no_args_returns_true(self) -> None:
"""actions set with insufficient args should return True (error displayed)."""
result = _actions_mod.handle_command("actions", ["set"])
assert result is True
def test_actions_set_bad_type_returns_true(self) -> None:
"""actions set with unknown type should return True (error displayed)."""
result = _actions_mod.handle_command("actions", ["set", "badtype"])
assert result is True
def test_route_command_no_cascade(self) -> None:
"""route_command should return True for handled-but-failed actions commands."""
modules = _daemon_mod.get_modules()
result = _daemon_mod.route_command("actions", ["nonexistent_xyz"], modules)
assert result is True, "route_command must not fall through on handled errors"
# ============================================================================
# Branch-health no-args fallback tests
# ============================================================================
class TestBranchHealthFallback:
"""Tests that branch-health with no args shows all-branches summary."""
def test_branch_health_no_args_returns_true(self) -> None:
"""branch-health with no args should return True (shows summary)."""
result = _activity_mod.handle_command("branch-health", [])
assert result is True
def test_branch_health_no_args_not_introspection(self) -> None:
"""branch-health with no args should NOT call print_introspection."""
with patch.object(_activity_mod, "print_introspection") as mock_intro:
_activity_mod.handle_command("branch-health", [])
mock_intro.assert_not_called()
def test_branch_health_help_flag(self) -> None:
"""branch-health --help should return True."""
result = _activity_mod.handle_command("branch-health", ["--help"])
assert result is True
@@ -0,0 +1,94 @@
# =================== AIPass ====================
# Name: test_wakeup_ops.py
# Description: Tests for the wakeup_ops facade module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for the wakeup_ops facade module (apps/modules/wakeup_ops.py)."""
from unittest.mock import patch
MODULE = "aipass.daemon.apps.modules.wakeup_ops"
# =============================================
# handle_command — routing
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.logger")
class TestHandleCommand:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _log, _con, _jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
assert handle_command("not-wakeup-ops", []) is False
def test_no_args_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
result = handle_command("wakeup-ops", [])
assert result is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("wakeup_ops Module" in c for c in calls)
def test_help_flag_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
assert handle_command("wakeup-ops", ["--help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("wakeup_ops Module" in c for c in calls)
def test_h_flag_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
assert handle_command("wakeup-ops", ["-h"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("wakeup_ops Module" in c for c in calls)
def test_help_word_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
assert handle_command("wakeup-ops", ["help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("wakeup_ops Module" in c for c in calls)
def test_status_arg_shows_info(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
assert handle_command("wakeup-ops", ["status"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Wakeup Ops" in c for c in calls)
def test_status_calls_log_operation(self, _log, _con, mock_jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
handle_command("wakeup-ops", ["status"])
mock_jh.log_operation.assert_called_once_with("wakeup_ops_status")
def test_status_prints_notifications_archived(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.wakeup_ops import handle_command
handle_command("wakeup-ops", ["status"])
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Notifications" in c for c in calls)
# =============================================
# print_introspection
# =============================================
@patch(f"{MODULE}.console")
class TestPrintIntrospection:
"""Tests for print_introspection output."""
def test_prints_module_header(self, mock_console):
from aipass.daemon.apps.modules.wakeup_ops import print_introspection
print_introspection()
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("wakeup_ops Module" in c for c in calls)