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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Apps package - Branch application modules and handlers
|
||||
from . import handlers # noqa: F401
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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 @@
|
||||
[]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
Executable
+417
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Docs
|
||||
|
||||
Documentation files for the `DAEMON` branch.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# Tests package for daemon
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user