From ee004c45686ba01da4087f8ee275c9d76b75513b Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 12 Jun 2026 01:29:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20revive=20dormant=20citizen=20(r?= =?UTF-8?q?evive-to-working=20ONLY)=20=E2=80=94=20scrub=206=20stale=20@ver?= =?UTF-8?q?a=20refs,=20add=20happy-path=20logger.info()=20central=20loggin?= =?UTF-8?q?g,=20fix=20Ruff/Introspection/Windows=5FCompat/Handler=5FImport?= =?UTF-8?q?/Imports=20(DPLAN-0203=20night=20shift).=20387=20tests,=20seedg?= =?UTF-8?q?o=20100%.=20Scheduler=20.daemon/=20redesign=20deferred=20to=20D?= =?UTF-8?q?PLAN-0204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/aipass/daemon/.aipass/README.md | 3 + .../daemon/.aipass/aipass_local_prompt.md | 56 ++ src/aipass/daemon/.claude/README.md | 5 + src/aipass/daemon/.gitignore | 14 + src/aipass/daemon/.seedgo/README.md | 5 + src/aipass/daemon/.seedgo/bypass.json | 152 +++++ src/aipass/daemon/README.md | 170 +++++ src/aipass/daemon/__init__.py | 0 src/aipass/daemon/apps/README.md | 8 + src/aipass/daemon/apps/__init__.py | 2 + src/aipass/daemon/apps/daemon.py | 214 ++++++ src/aipass/daemon/apps/daemon_wakeup.py | 270 ++++++++ src/aipass/daemon/apps/extensions/__init__.py | 1 + src/aipass/daemon/apps/handlers/README.md | 5 + src/aipass/daemon/apps/handlers/__init__.py | 115 ++++ .../daemon/apps/handlers/actions/__init__.py | 0 .../apps/handlers/actions/action_processor.py | 349 ++++++++++ .../apps/handlers/actions/actions_registry.py | 550 ++++++++++++++++ .../daemon/apps/handlers/json/__init__.py | 1 + .../daemon/apps/handlers/json/json_handler.py | 246 +++++++ .../apps/handlers/monitoring/__init__.py | 56 ++ .../handlers/monitoring/activity_collector.py | 326 +++++++++ .../apps/handlers/monitoring/memory_health.py | 408 ++++++++++++ .../handlers/monitoring/red_flag_detector.py | 354 ++++++++++ .../handlers/monitoring/report_generator.py | 489 ++++++++++++++ .../daemon/apps/handlers/schedule/__init__.py | 38 ++ .../apps/handlers/schedule/task_registry.py | 588 +++++++++++++++++ .../daemon/apps/handlers/telegram/__init__.py | 1 + .../daemon/apps/handlers/update/__init__.py | 1 + .../apps/handlers/update/data_loader.py | 108 +++ .../daemon/apps/json_templates/__init__.py | 1 + .../apps/json_templates/default/config.json | 9 + .../apps/json_templates/default/data.json | 8 + .../apps/json_templates/default/log.json | 1 + src/aipass/daemon/apps/modules/README.md | 5 + src/aipass/daemon/apps/modules/__init__.py | 1 + src/aipass/daemon/apps/modules/actions.py | 560 ++++++++++++++++ .../daemon/apps/modules/activity_report.py | 315 +++++++++ src/aipass/daemon/apps/modules/schedule.py | 436 ++++++++++++ .../daemon/apps/modules/scheduler_ops.py | 133 ++++ src/aipass/daemon/apps/modules/update.py | 200 ++++++ src/aipass/daemon/apps/modules/wakeup_ops.py | 68 ++ src/aipass/daemon/apps/plugins/README.md | 5 + src/aipass/daemon/apps/plugins/__init__.py | 98 +++ .../daemon/apps/plugins/community_rotation.py | 447 +++++++++++++ src/aipass/daemon/apps/plugins/daily_audit.py | 50 ++ src/aipass/daemon/apps/plugins/heartbeat.py | 62 ++ src/aipass/daemon/apps/scheduler_cron.py | 417 ++++++++++++ src/aipass/daemon/docs/README.md | 3 + src/aipass/daemon/pytest.ini | 12 + src/aipass/daemon/templates/README.md | 5 + src/aipass/daemon/tests/README.md | 6 + src/aipass/daemon/tests/__init__.py | 1 + src/aipass/daemon/tests/conftest.py | 65 ++ .../daemon/tests/test_actions_module.py | 456 +++++++++++++ .../daemon/tests/test_actions_registry.py | 359 ++++++++++ .../daemon/tests/test_activity_report.py | 243 +++++++ src/aipass/daemon/tests/test_cli_routing.py | 120 ++++ src/aipass/daemon/tests/test_contracts.py | 313 +++++++++ src/aipass/daemon/tests/test_data_loader.py | 309 +++++++++ .../daemon/tests/test_error_resilience.py | 170 +++++ src/aipass/daemon/tests/test_json_handler.py | 621 ++++++++++++++++++ src/aipass/daemon/tests/test_memory_health.py | 478 ++++++++++++++ .../daemon/tests/test_red_flag_detector.py | 500 ++++++++++++++ .../daemon/tests/test_schedule_module.py | 352 ++++++++++ .../daemon/tests/test_scheduler_cron.py | 479 ++++++++++++++ src/aipass/daemon/tests/test_scheduler_ops.py | 114 ++++ src/aipass/daemon/tests/test_task_registry.py | 532 +++++++++++++++ .../daemon/tests/test_update_and_errors.py | 143 ++++ src/aipass/daemon/tests/test_wakeup_ops.py | 94 +++ 70 files changed, 12726 insertions(+) create mode 100644 src/aipass/daemon/.aipass/README.md create mode 100644 src/aipass/daemon/.aipass/aipass_local_prompt.md create mode 100644 src/aipass/daemon/.claude/README.md create mode 100644 src/aipass/daemon/.gitignore create mode 100644 src/aipass/daemon/.seedgo/README.md create mode 100644 src/aipass/daemon/.seedgo/bypass.json create mode 100644 src/aipass/daemon/README.md create mode 100644 src/aipass/daemon/__init__.py create mode 100644 src/aipass/daemon/apps/README.md create mode 100644 src/aipass/daemon/apps/__init__.py create mode 100644 src/aipass/daemon/apps/daemon.py create mode 100644 src/aipass/daemon/apps/daemon_wakeup.py create mode 100644 src/aipass/daemon/apps/extensions/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/README.md create mode 100644 src/aipass/daemon/apps/handlers/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/actions/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/actions/action_processor.py create mode 100644 src/aipass/daemon/apps/handlers/actions/actions_registry.py create mode 100644 src/aipass/daemon/apps/handlers/json/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/json/json_handler.py create mode 100644 src/aipass/daemon/apps/handlers/monitoring/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/monitoring/activity_collector.py create mode 100644 src/aipass/daemon/apps/handlers/monitoring/memory_health.py create mode 100644 src/aipass/daemon/apps/handlers/monitoring/red_flag_detector.py create mode 100644 src/aipass/daemon/apps/handlers/monitoring/report_generator.py create mode 100644 src/aipass/daemon/apps/handlers/schedule/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/schedule/task_registry.py create mode 100644 src/aipass/daemon/apps/handlers/telegram/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/update/__init__.py create mode 100644 src/aipass/daemon/apps/handlers/update/data_loader.py create mode 100644 src/aipass/daemon/apps/json_templates/__init__.py create mode 100644 src/aipass/daemon/apps/json_templates/default/config.json create mode 100644 src/aipass/daemon/apps/json_templates/default/data.json create mode 100644 src/aipass/daemon/apps/json_templates/default/log.json create mode 100644 src/aipass/daemon/apps/modules/README.md create mode 100644 src/aipass/daemon/apps/modules/__init__.py create mode 100644 src/aipass/daemon/apps/modules/actions.py create mode 100644 src/aipass/daemon/apps/modules/activity_report.py create mode 100644 src/aipass/daemon/apps/modules/schedule.py create mode 100644 src/aipass/daemon/apps/modules/scheduler_ops.py create mode 100644 src/aipass/daemon/apps/modules/update.py create mode 100644 src/aipass/daemon/apps/modules/wakeup_ops.py create mode 100644 src/aipass/daemon/apps/plugins/README.md create mode 100644 src/aipass/daemon/apps/plugins/__init__.py create mode 100644 src/aipass/daemon/apps/plugins/community_rotation.py create mode 100644 src/aipass/daemon/apps/plugins/daily_audit.py create mode 100644 src/aipass/daemon/apps/plugins/heartbeat.py create mode 100755 src/aipass/daemon/apps/scheduler_cron.py create mode 100644 src/aipass/daemon/docs/README.md create mode 100644 src/aipass/daemon/pytest.ini create mode 100644 src/aipass/daemon/templates/README.md create mode 100644 src/aipass/daemon/tests/README.md create mode 100644 src/aipass/daemon/tests/__init__.py create mode 100644 src/aipass/daemon/tests/conftest.py create mode 100644 src/aipass/daemon/tests/test_actions_module.py create mode 100644 src/aipass/daemon/tests/test_actions_registry.py create mode 100644 src/aipass/daemon/tests/test_activity_report.py create mode 100644 src/aipass/daemon/tests/test_cli_routing.py create mode 100644 src/aipass/daemon/tests/test_contracts.py create mode 100644 src/aipass/daemon/tests/test_data_loader.py create mode 100644 src/aipass/daemon/tests/test_error_resilience.py create mode 100644 src/aipass/daemon/tests/test_json_handler.py create mode 100644 src/aipass/daemon/tests/test_memory_health.py create mode 100644 src/aipass/daemon/tests/test_red_flag_detector.py create mode 100644 src/aipass/daemon/tests/test_schedule_module.py create mode 100644 src/aipass/daemon/tests/test_scheduler_cron.py create mode 100644 src/aipass/daemon/tests/test_scheduler_ops.py create mode 100644 src/aipass/daemon/tests/test_task_registry.py create mode 100644 src/aipass/daemon/tests/test_update_and_errors.py create mode 100644 src/aipass/daemon/tests/test_wakeup_ops.py diff --git a/src/aipass/daemon/.aipass/README.md b/src/aipass/daemon/.aipass/README.md new file mode 100644 index 00000000..e14a536a --- /dev/null +++ b/src/aipass/daemon/.aipass/README.md @@ -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. diff --git a/src/aipass/daemon/.aipass/aipass_local_prompt.md b/src/aipass/daemon/.aipass/aipass_local_prompt.md new file mode 100644 index 00000000..b4612c1c --- /dev/null +++ b/src/aipass/daemon/.aipass/aipass_local_prompt.md @@ -0,0 +1,56 @@ +# DAEMON — Branch Context + + +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 diff --git a/src/aipass/daemon/.claude/README.md b/src/aipass/daemon/.claude/README.md new file mode 100644 index 00000000..23885aea --- /dev/null +++ b/src/aipass/daemon/.claude/README.md @@ -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. diff --git a/src/aipass/daemon/.gitignore b/src/aipass/daemon/.gitignore new file mode 100644 index 00000000..9cf1dfc4 --- /dev/null +++ b/src/aipass/daemon/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +.env +*.egg-info/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +dist/ +build/ +*.log +*.tmp +*.swp diff --git a/src/aipass/daemon/.seedgo/README.md b/src/aipass/daemon/.seedgo/README.md new file mode 100644 index 00000000..f7a81aa1 --- /dev/null +++ b/src/aipass/daemon/.seedgo/README.md @@ -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. diff --git a/src/aipass/daemon/.seedgo/bypass.json b/src/aipass/daemon/.seedgo/bypass.json new file mode 100644 index 00000000..96075c23 --- /dev/null +++ b/src/aipass/daemon/.seedgo/bypass.json @@ -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" + } + } +} diff --git a/src/aipass/daemon/README.md b/src/aipass/daemon/README.md new file mode 100644 index 00000000..ed5a29d2 --- /dev/null +++ b/src/aipass/daemon/README.md @@ -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 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 --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) diff --git a/src/aipass/daemon/__init__.py b/src/aipass/daemon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/daemon/apps/README.md b/src/aipass/daemon/apps/README.md new file mode 100644 index 00000000..cdab7970 --- /dev/null +++ b/src/aipass/daemon/apps/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. diff --git a/src/aipass/daemon/apps/__init__.py b/src/aipass/daemon/apps/__init__.py new file mode 100644 index 00000000..9fa3b6bc --- /dev/null +++ b/src/aipass/daemon/apps/__init__.py @@ -0,0 +1,2 @@ +# Apps package - Branch application modules and handlers +from . import handlers # noqa: F401 diff --git a/src/aipass/daemon/apps/daemon.py b/src/aipass/daemon/apps/daemon.py new file mode 100644 index 00000000..c4fa4900 --- /dev/null +++ b/src/aipass/daemon/apps/daemon.py @@ -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 [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 --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) diff --git a/src/aipass/daemon/apps/daemon_wakeup.py b/src/aipass/daemon/apps/daemon_wakeup.py new file mode 100644 index 00000000..4da33e17 --- /dev/null +++ b/src/aipass/daemon/apps/daemon_wakeup.py @@ -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) diff --git a/src/aipass/daemon/apps/extensions/__init__.py b/src/aipass/daemon/apps/extensions/__init__.py new file mode 100644 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/daemon/apps/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/daemon/apps/handlers/README.md b/src/aipass/daemon/apps/handlers/README.md new file mode 100644 index 00000000..dfb2ece9 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/README.md @@ -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. diff --git a/src/aipass/daemon/apps/handlers/__init__.py b/src/aipass/daemon/apps/handlers/__init__.py new file mode 100644 index 00000000..7ead18d4 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/__init__.py @@ -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 or + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + 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. import \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() diff --git a/src/aipass/daemon/apps/handlers/actions/__init__.py b/src/aipass/daemon/apps/handlers/actions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/daemon/apps/handlers/actions/action_processor.py b/src/aipass/daemon/apps/handlers/actions/action_processor.py new file mode 100644 index 00000000..25f8a85c --- /dev/null +++ b/src/aipass/daemon/apps/handlers/actions/action_processor.py @@ -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 diff --git a/src/aipass/daemon/apps/handlers/actions/actions_registry.py b/src/aipass/daemon/apps/handlers/actions/actions_registry.py new file mode 100644 index 00000000..8581484d --- /dev/null +++ b/src/aipass/daemon/apps/handlers/actions/actions_registry.py @@ -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 diff --git a/src/aipass/daemon/apps/handlers/json/__init__.py b/src/aipass/daemon/apps/handlers/json/__init__.py new file mode 100644 index 00000000..ed524be5 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""JSON Handlers - Universal JSON operations for daemon branch""" diff --git a/src/aipass/daemon/apps/handlers/json/json_handler.py b/src/aipass/daemon/apps/handlers/json/json_handler.py new file mode 100644 index 00000000..232e357e --- /dev/null +++ b/src/aipass/daemon/apps/handlers/json/json_handler.py @@ -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() diff --git a/src/aipass/daemon/apps/handlers/monitoring/__init__.py b/src/aipass/daemon/apps/handlers/monitoring/__init__.py new file mode 100644 index 00000000..eaf49ff3 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/monitoring/__init__.py @@ -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", +] diff --git a/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py b/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py new file mode 100644 index 00000000..d7d05803 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py @@ -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']}") diff --git a/src/aipass/daemon/apps/handlers/monitoring/memory_health.py b/src/aipass/daemon/apps/handlers/monitoring/memory_health.py new file mode 100644 index 00000000..ff5cd077 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/monitoring/memory_health.py @@ -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}") diff --git a/src/aipass/daemon/apps/handlers/monitoring/red_flag_detector.py b/src/aipass/daemon/apps/handlers/monitoring/red_flag_detector.py new file mode 100644 index 00000000..f4545553 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/monitoring/red_flag_detector.py @@ -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.") diff --git a/src/aipass/daemon/apps/handlers/monitoring/report_generator.py b/src/aipass/daemon/apps/handlers/monitoring/report_generator.py new file mode 100644 index 00000000..b767a156 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/monitoring/report_generator.py @@ -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, + } diff --git a/src/aipass/daemon/apps/handlers/schedule/__init__.py b/src/aipass/daemon/apps/handlers/schedule/__init__.py new file mode 100644 index 00000000..4a7d5be6 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/schedule/__init__.py @@ -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", +] diff --git a/src/aipass/daemon/apps/handlers/schedule/task_registry.py b/src/aipass/daemon/apps/handlers/schedule/task_registry.py new file mode 100644 index 00000000..6dd70815 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/schedule/task_registry.py @@ -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() diff --git a/src/aipass/daemon/apps/handlers/telegram/__init__.py b/src/aipass/daemon/apps/handlers/telegram/__init__.py new file mode 100644 index 00000000..cbcdd9d4 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/telegram/__init__.py @@ -0,0 +1 @@ +"""Telegram handlers - ARCHIVED. See .archive/ directory. Telegram moving to skills system.""" diff --git a/src/aipass/daemon/apps/handlers/update/__init__.py b/src/aipass/daemon/apps/handlers/update/__init__.py new file mode 100644 index 00000000..61762d07 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/update/__init__.py @@ -0,0 +1 @@ +# Update handlers package diff --git a/src/aipass/daemon/apps/handlers/update/data_loader.py b/src/aipass/daemon/apps/handlers/update/data_loader.py new file mode 100644 index 00000000..ee1b90af --- /dev/null +++ b/src/aipass/daemon/apps/handlers/update/data_loader.py @@ -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() + ] diff --git a/src/aipass/daemon/apps/json_templates/__init__.py b/src/aipass/daemon/apps/json_templates/__init__.py new file mode 100644 index 00000000..5d00b535 --- /dev/null +++ b/src/aipass/daemon/apps/json_templates/__init__.py @@ -0,0 +1 @@ +# JSON Templates package - Default JSON file templates diff --git a/src/aipass/daemon/apps/json_templates/default/config.json b/src/aipass/daemon/apps/json_templates/default/config.json new file mode 100644 index 00000000..3c2049cd --- /dev/null +++ b/src/aipass/daemon/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "{{CURRENT_DATE}}", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/daemon/apps/json_templates/default/data.json b/src/aipass/daemon/apps/json_templates/default/data.json new file mode 100644 index 00000000..e2dba223 --- /dev/null +++ b/src/aipass/daemon/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "{{CURRENT_DATE}}", + "last_updated": "{{CURRENT_DATE}}", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/daemon/apps/json_templates/default/log.json b/src/aipass/daemon/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/daemon/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/daemon/apps/modules/README.md b/src/aipass/daemon/apps/modules/README.md new file mode 100644 index 00000000..d4617cb5 --- /dev/null +++ b/src/aipass/daemon/apps/modules/README.md @@ -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. diff --git a/src/aipass/daemon/apps/modules/__init__.py b/src/aipass/daemon/apps/modules/__init__.py new file mode 100644 index 00000000..4cf76dce --- /dev/null +++ b/src/aipass/daemon/apps/modules/__init__.py @@ -0,0 +1 @@ +# Modules package - Branch-specific functionality modules diff --git a/src/aipass/daemon/apps/modules/actions.py b/src/aipass/daemon/apps/modules/actions.py new file mode 100644 index 00000000..dcb13576 --- /dev/null +++ b/src/aipass/daemon/apps/modules/actions.py @@ -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 info") + console.print(" drone @daemon actions on") + console.print(" drone @daemon actions off") + console.print(' drone @daemon actions set reminder "message" [--to @branch]') + console.print(' drone @daemon actions set schedule @branch "prompt" [time]') + console.print(" drone @daemon actions migrate") + console.print(" drone @daemon actions delete ") + console.print() + + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" list List all registered actions with status") + console.print(" info Show detailed view of a single action") + console.print(" on Enable an action") + console.print(" off Disable an action") + console.print(" set Create a new reminder or schedule") + console.print(" migrate Import existing plugins into registry") + console.print(" delete 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 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 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 "message" [--to @branch]'.""" + if len(args) < 2: + _error('Usage: actions set reminder "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" [time_spec]'.""" + if len(args) < 3: + _error('Usage: actions set schedule @branch "prompt" [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 '.""" + if not args: + _error("Action ID required: actions delete ") + 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 ...") + 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() diff --git a/src/aipass/daemon/apps/modules/activity_report.py b/src/aipass/daemon/apps/modules/activity_report.py new file mode 100644 index 00000000..e876f370 --- /dev/null +++ b/src/aipass/daemon/apps/modules/activity_report.py @@ -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(" 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 : 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 [--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() diff --git a/src/aipass/daemon/apps/modules/schedule.py b/src/aipass/daemon/apps/modules/schedule.py new file mode 100644 index 00000000..c3020771 --- /dev/null +++ b/src/aipass/daemon/apps/modules/schedule.py @@ -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 ") + 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 --to @branch [--message "details"]') + return False + + if not parsed.task: + _error("Task description is required") + console.print('[dim]Usage: schedule create "task" --due --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 [/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() diff --git a/src/aipass/daemon/apps/modules/scheduler_ops.py b/src/aipass/daemon/apps/modules/scheduler_ops.py new file mode 100644 index 00000000..0a0ce246 --- /dev/null +++ b/src/aipass/daemon/apps/modules/scheduler_ops.py @@ -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 diff --git a/src/aipass/daemon/apps/modules/update.py b/src/aipass/daemon/apps/modules/update.py new file mode 100644 index 00000000..02e27ad8 --- /dev/null +++ b/src/aipass/daemon/apps/modules/update.py @@ -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() diff --git a/src/aipass/daemon/apps/modules/wakeup_ops.py b/src/aipass/daemon/apps/modules/wakeup_ops.py new file mode 100644 index 00000000..28a8ef27 --- /dev/null +++ b/src/aipass/daemon/apps/modules/wakeup_ops.py @@ -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 diff --git a/src/aipass/daemon/apps/plugins/README.md b/src/aipass/daemon/apps/plugins/README.md new file mode 100644 index 00000000..5168dc78 --- /dev/null +++ b/src/aipass/daemon/apps/plugins/README.md @@ -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. diff --git a/src/aipass/daemon/apps/plugins/__init__.py b/src/aipass/daemon/apps/plugins/__init__.py new file mode 100644 index 00000000..821f4c31 --- /dev/null +++ b/src/aipass/daemon/apps/plugins/__init__.py @@ -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 diff --git a/src/aipass/daemon/apps/plugins/community_rotation.py b/src/aipass/daemon/apps/plugins/community_rotation.py new file mode 100644 index 00000000..83a78c3d --- /dev/null +++ b/src/aipass/daemon/apps/plugins/community_rotation.py @@ -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 \n" + ' - Reply if needed: ai_mail reply "message"\n' + " - Close stale/informational emails: ai_mail close \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 "Your response"\n' + " drone commons vote 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), + } diff --git a/src/aipass/daemon/apps/plugins/daily_audit.py b/src/aipass/daemon/apps/plugins/daily_audit.py new file mode 100644 index 00000000..dd6d03af --- /dev/null +++ b/src/aipass/daemon/apps/plugins/daily_audit.py @@ -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"], + } diff --git a/src/aipass/daemon/apps/plugins/heartbeat.py b/src/aipass/daemon/apps/plugins/heartbeat.py new file mode 100644 index 00000000..fa63b39f --- /dev/null +++ b/src/aipass/daemon/apps/plugins/heartbeat.py @@ -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"], + } diff --git a/src/aipass/daemon/apps/scheduler_cron.py b/src/aipass/daemon/apps/scheduler_cron.py new file mode 100755 index 00000000..0b2f2436 --- /dev/null +++ b/src/aipass/daemon/apps/scheduler_cron.py @@ -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) diff --git a/src/aipass/daemon/docs/README.md b/src/aipass/daemon/docs/README.md new file mode 100644 index 00000000..bb09ba70 --- /dev/null +++ b/src/aipass/daemon/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +Documentation files for the `DAEMON` branch. diff --git a/src/aipass/daemon/pytest.ini b/src/aipass/daemon/pytest.ini new file mode 100644 index 00000000..038684f6 --- /dev/null +++ b/src/aipass/daemon/pytest.ini @@ -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 diff --git a/src/aipass/daemon/templates/README.md b/src/aipass/daemon/templates/README.md new file mode 100644 index 00000000..dc4f4f38 --- /dev/null +++ b/src/aipass/daemon/templates/README.md @@ -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). diff --git a/src/aipass/daemon/tests/README.md b/src/aipass/daemon/tests/README.md new file mode 100644 index 00000000..e8a48ea8 --- /dev/null +++ b/src/aipass/daemon/tests/README.md @@ -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. diff --git a/src/aipass/daemon/tests/__init__.py b/src/aipass/daemon/tests/__init__.py new file mode 100644 index 00000000..928e4c37 --- /dev/null +++ b/src/aipass/daemon/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for daemon diff --git a/src/aipass/daemon/tests/conftest.py b/src/aipass/daemon/tests/conftest.py new file mode 100644 index 00000000..1041a321 --- /dev/null +++ b/src/aipass/daemon/tests/conftest.py @@ -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 diff --git a/src/aipass/daemon/tests/test_actions_module.py b/src/aipass/daemon/tests/test_actions_module.py new file mode 100644 index 00000000..7451f5c3 --- /dev/null +++ b/src/aipass/daemon/tests/test_actions_module.py @@ -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() diff --git a/src/aipass/daemon/tests/test_actions_registry.py b/src/aipass/daemon/tests/test_actions_registry.py new file mode 100644 index 00000000..fc52b227 --- /dev/null +++ b/src/aipass/daemon/tests/test_actions_registry.py @@ -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 diff --git a/src/aipass/daemon/tests/test_activity_report.py b/src/aipass/daemon/tests/test_activity_report.py new file mode 100644 index 00000000..d5b0a11c --- /dev/null +++ b/src/aipass/daemon/tests/test_activity_report.py @@ -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 diff --git a/src/aipass/daemon/tests/test_cli_routing.py b/src/aipass/daemon/tests/test_cli_routing.py new file mode 100644 index 00000000..330fc669 --- /dev/null +++ b/src/aipass/daemon/tests/test_cli_routing.py @@ -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" diff --git a/src/aipass/daemon/tests/test_contracts.py b/src/aipass/daemon/tests/test_contracts.py new file mode 100644 index 00000000..54dbfa54 --- /dev/null +++ b/src/aipass/daemon/tests/test_contracts.py @@ -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) diff --git a/src/aipass/daemon/tests/test_data_loader.py b/src/aipass/daemon/tests/test_data_loader.py new file mode 100644 index 00000000..4c5a190f --- /dev/null +++ b/src/aipass/daemon/tests/test_data_loader.py @@ -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("<<>>", 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 == [] diff --git a/src/aipass/daemon/tests/test_error_resilience.py b/src/aipass/daemon/tests/test_error_resilience.py new file mode 100644 index 00000000..b7aa1a48 --- /dev/null +++ b/src/aipass/daemon/tests/test_error_resilience.py @@ -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 diff --git a/src/aipass/daemon/tests/test_json_handler.py b/src/aipass/daemon/tests/test_json_handler.py new file mode 100644 index 00000000..942ebebb --- /dev/null +++ b/src/aipass/daemon/tests/test_json_handler.py @@ -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" diff --git a/src/aipass/daemon/tests/test_memory_health.py b/src/aipass/daemon/tests/test_memory_health.py new file mode 100644 index 00000000..f34d7160 --- /dev/null +++ b/src/aipass/daemon/tests/test_memory_health.py @@ -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) diff --git a/src/aipass/daemon/tests/test_red_flag_detector.py b/src/aipass/daemon/tests/test_red_flag_detector.py new file mode 100644 index 00000000..be863c65 --- /dev/null +++ b/src/aipass/daemon/tests/test_red_flag_detector.py @@ -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 diff --git a/src/aipass/daemon/tests/test_schedule_module.py b/src/aipass/daemon/tests/test_schedule_module.py new file mode 100644 index 00000000..3f2bf336 --- /dev/null +++ b/src/aipass/daemon/tests/test_schedule_module.py @@ -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 diff --git a/src/aipass/daemon/tests/test_scheduler_cron.py b/src/aipass/daemon/tests/test_scheduler_cron.py new file mode 100644 index 00000000..b4b6ed3b --- /dev/null +++ b/src/aipass/daemon/tests/test_scheduler_cron.py @@ -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 diff --git a/src/aipass/daemon/tests/test_scheduler_ops.py b/src/aipass/daemon/tests/test_scheduler_ops.py new file mode 100644 index 00000000..af916328 --- /dev/null +++ b/src/aipass/daemon/tests/test_scheduler_ops.py @@ -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) diff --git a/src/aipass/daemon/tests/test_task_registry.py b/src/aipass/daemon/tests/test_task_registry.py new file mode 100644 index 00000000..c21f1b64 --- /dev/null +++ b/src/aipass/daemon/tests/test_task_registry.py @@ -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) diff --git a/src/aipass/daemon/tests/test_update_and_errors.py b/src/aipass/daemon/tests/test_update_and_errors.py new file mode 100644 index 00000000..f5c930f5 --- /dev/null +++ b/src/aipass/daemon/tests/test_update_and_errors.py @@ -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 diff --git a/src/aipass/daemon/tests/test_wakeup_ops.py b/src/aipass/daemon/tests/test_wakeup_ops.py new file mode 100644 index 00000000..dae42026 --- /dev/null +++ b/src/aipass/daemon/tests/test_wakeup_ops.py @@ -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)