feat(system): feat(system): S81: aipass init v2 — real templates, spawn routing, README Quick Start (#206)
Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
@@ -10,7 +10,7 @@ On any greeting, silently read these files from CWD and run the commands — no
|
||||
|
||||
**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail — don't ask,
|
||||
**list:** `dropbox` files. Ignore README.md
|
||||
**list:** `dropbox` files. Always report dropbox status,Ignore README.md
|
||||
**Run:** `git status`
|
||||
|
||||
## Security
|
||||
|
||||
@@ -4,23 +4,42 @@
|
||||
|
||||
> The living record. What happened, what's changing, what matters.
|
||||
|
||||
**Last updated:** 2026-04-05 | **Session:** 76 | **PRs merged:** 192
|
||||
**Last updated:** 2026-04-09 | **Session:** 82 | **PRs merged:** 205
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
- **15 branches** operational
|
||||
- **10 core agents** operational (streamlined from 15 — backup, daemon, api, commons, skills split to standalone projects)
|
||||
- **4,900+ tests** system-wide
|
||||
- **192 PRs** merged
|
||||
- **76 sessions** of development
|
||||
- **205 PRs** merged
|
||||
- **82 sessions** of development
|
||||
- **Multi-CLI support** — Claude Code, Codex (GPT-5.4), Gemini all integrated with hooks, identity, skills
|
||||
- **Prax model tags** — monitor shows [BRANCH/MODEL] for all three CLIs
|
||||
- **Sentinel** — AIPass's first external project (Claude Code JSONL analyzer)
|
||||
- **Nexus vision** — personal AI companion architecture (co-founded with GPT-4o, March 2025)
|
||||
- **Vera Studio** — standalone AI-driven brand studio for promotion (separate from devpulse construction)
|
||||
- **aipass init v2** — real templates, next steps, `aipass init agent` command
|
||||
- **Goldfish panel** — 7-round multi-model README review process (Claude + Codex + Gemini)
|
||||
- **PyPI** — `pip install aipass` works (v2.0.0)
|
||||
|
||||
## Recent Sessions
|
||||
|
||||
### S82 — Core 10 Split: Dependency Audit + README Update (2026-04-09)
|
||||
Decided to split 5 agents to standalone projects (backup, daemon, api, commons, skills). 6 parallel agents verified zero code dependencies from core 10 → split 5. Updated README from 15 → 10 agents. All agent tables, tree diagrams, TOC, metrics updated. CLI dispatched for src/ directory in init + CWD-aware sync-registry for spawn.
|
||||
|
||||
### S81 — aipass init v2 + Vera Studio (2026-04-08)
|
||||
TDPLAN-0002: Complete init overhaul. CLI, spawn, drone worked in parallel. Init now creates 10 items with real content (CLAUDE.md, AGENTS.md, GEMINI.md, global prompt, README, .gitignore, hooks, settings). `aipass init agent` routes to spawn. Spawn added --template flag + CLAUDE.md to builder template. Drone added spawn to routing_config.json. Prax fixed watchdog with --daemon mode + statusline indicator. Vera Studio project created. Patrick testing as real first-time user — found .trinity shouldn't be in project root, local prompt is agent-level only. CLI fixed both. DPLAN-0105 (promotion prep), DPLAN-0106 (watchdog v2). PRs #204-205.
|
||||
|
||||
### S80 — README Marathon + 14-Branch Audit (2026-04-07/08)
|
||||
FPLAN-0165 executed (README 366→279 lines). Goldfish Rounds 5-7: R5 approval (8/8.5/9 ratings), R6 branch deep dive (flow+memory undersold, honest tagging builds credibility), R7 hands-on CLI (routing 100%, nothing broke). 14 branches dispatched simultaneously for state audit — all completed in 15 min, 3,600+ tests, 11/14 at 100% seedgo. README Round 7 update (flow/memory lifecycle, transparency sentence). CLI init bug fixed (double prefix). Watchdog race condition fixed. PyPI 2.0.0 published. ~/.secrets/ blocked across all 3 CLIs. DPLAN-0099 updated through 7 rounds. Gemini free tier dead. PRs #202-203.
|
||||
|
||||
### S79 — README Overhaul + Goldfish Panel (2026-04-07)
|
||||
README overhaul with 4 Goldfish rounds (Claude+Codex+Gemini reviews). PyPI published (aipass 2.0.0). Security: ~/.secrets/ blocked across all 3 CLIs. Memory central_writer path bug fixed. Breadcrumb architecture + collaboration angle captured. FPLAN-0165 master plan. PR #201 merged.
|
||||
|
||||
### S78 — Compass + Navigator + TDPLAN (2026-04-06)
|
||||
Compass v0.1 built (25 judgment fragments vectorized). Project-vs-agent distinction discovered (DPLAN-0104). Navigator agent pattern proven (tmux + brief + let work). TDPLAN template created. Global prompt: gitignore rule. PR #199.
|
||||
|
||||
### S77 — README Value Prop Research (2026-04-06)
|
||||
Cross-platform research (10 agents), README value prop overhaul (7 agents), competitive landscape, fresh outside perspective. DPLAN-0098+0099. PR #195 merged.
|
||||
|
||||
### S76 — Multi-CLI Integration Into Main (2026-04-05)
|
||||
Ported Codex and Gemini from Docker prototype into the AIPass repo. Created AGENTS.md, GEMINI.md, .codex/ (hooks + skills), .gemini/ (hooks + skills). Updated setup.sh to detect CLIs and wire hooks automatically. Prax built Codex/Gemini log adapters, model tags ([DEVPULSE/OPUS], [DEVPULSE/GPT-5.4], [DEVPULSE/GEMINI-3-FLASH]), inotify errno-specific messages, and CWD branch detection. Fixed AGENTS.md startup behavior. PRs #189-192.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ A local multi-agent framework where your AI assistants keep their memory between
|
||||
- [What AIPass Does](#what-aipass-does)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How It Works](#how-it-works)
|
||||
- [The 15 Agents](#the-15-agents)
|
||||
- [The 10 Agents](#the-10-agents)
|
||||
- [CLI Support](#cli-support)
|
||||
- [Project Status](#project-status)
|
||||
- [Requirements](#requirements)
|
||||
@@ -96,12 +96,12 @@ That's it. Your agent has identity, memory, a mailbox, and knows what AIPass is.
|
||||
|
||||
### Explore the full framework
|
||||
|
||||
Clone the repo to see all 15 agents working together — the reference implementation:
|
||||
Clone the repo to see all 10 agents working together — the reference implementation:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./setup.sh # Creates venv, installs, bootstraps 15 agents
|
||||
./setup.sh # Creates venv, installs, bootstraps 10 agents
|
||||
drone systems # See all agents
|
||||
|
||||
cd src/aipass/devpulse
|
||||
@@ -148,7 +148,7 @@ drone @ai_mail dispatch @memory "Archive old sessions" "Find sessions older than
|
||||
- **Team mode (most of the time):** Talk to `devpulse`, dispatch work across the team. Agents work in parallel and report back.
|
||||
- **Direct mode (for deeper work):** `cd src/aipass/memory && claude` — work one-on-one with a specialist when the problem needs focused domain expertise.
|
||||
|
||||
**AIPass ships with 15 specialist agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale:
|
||||
**AIPass ships with 10 core agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale:
|
||||
|
||||
```
|
||||
devpulse (orchestrator)
|
||||
@@ -159,15 +159,15 @@ devpulse (orchestrator)
|
||||
├── flow — plan lifecycle, templates, auto-archival
|
||||
├── spawn — creates new agents anywhere on your filesystem
|
||||
├── memory — automatic archival, ChromaDB, semantic search
|
||||
├── backup — versioned backups + Google Drive sync
|
||||
└── ...and 6 more specialists
|
||||
├── trigger — event-driven automation + self-healing
|
||||
└── cli — terminal formatting and rich output
|
||||
```
|
||||
|
||||
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
|
||||
|
||||
---
|
||||
|
||||
## The 15 Agents
|
||||
## The 10 Agents
|
||||
|
||||
You don't need to memorize this list. Start with `devpulse`, use `drone` to reach any agent, and learn the rest as your workflow expands.
|
||||
|
||||
@@ -182,9 +182,6 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
|
||||
| [**memory**](src/aipass/memory/README.md) | Memory lifecycle — automatic archival, ChromaDB vectors, semantic search |
|
||||
| [**spawn**](src/aipass/spawn/README.md) | Creates new agents from templates |
|
||||
|
||||
<details>
|
||||
<summary>See all 15 agents</summary>
|
||||
|
||||
**Quality and operations** — how the system stays healthy:
|
||||
|
||||
| Agent | Role |
|
||||
@@ -193,19 +190,7 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
|
||||
**Support** — everything else:
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
|
||||
| [**daemon**](src/aipass/daemon/README.md) | Background scheduler with cron jobs |
|
||||
| [**backup**](src/aipass/backup/README.md) | Snapshots, versioned backups, Google Drive sync |
|
||||
| [**api**](src/aipass/api/README.md) | LLM access via OpenRouter (optional) |
|
||||
| [**commons**](src/commons/README.md) | Community space for agent updates and discussion |
|
||||
| [**skills**](src/skills/README.md) | Reusable capabilities agents can invoke |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -229,7 +214,7 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Agents | 15 |
|
||||
| Agents | 10 |
|
||||
| Quality standards | 33 automated checks |
|
||||
| Tests | 4,900+ (across all agents) |
|
||||
| PRs merged | 192+ (created by agents, reviewed by human) |
|
||||
@@ -245,7 +230,7 @@ For detailed session history, see [HERALD.md](HERALD.md).
|
||||
- Python 3.10+
|
||||
- At least one AI CLI: Claude Code (recommended), Codex, or Gemini CLI
|
||||
- `sudo` access (for global CLI symlinks)
|
||||
- API keys optional (only for the `api` agent — OpenRouter/OpenAI)
|
||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||
- **Platforms:** Linux (fully tested), macOS (untested, should work), Windows via WSL2
|
||||
|
||||
---
|
||||
@@ -268,6 +253,6 @@ This works because AIPass runs each CLI as an **official subprocess** — the sa
|
||||
|
||||
Claude Code is proprietary but officially supports hooks and subprocess usage. Codex and Gemini CLI are open source (Apache 2.0).
|
||||
|
||||
> API keys are only needed for the optional `api` agent (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
||||
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -12,15 +12,15 @@ Init Bootstrap Handler - PRIVATE implementation
|
||||
Business logic for `aipass init`. Creates the project scaffold:
|
||||
1. {NAME}_REGISTRY.json — project registry with UUID
|
||||
2. .aipass/aipass_global_prompt.md — global prompt (injected every turn)
|
||||
3. .aipass/aipass_local_prompt.md — local prompt skeleton
|
||||
4. CLAUDE.md — project prompt (Claude Code reads this)
|
||||
5. AGENTS.md — Codex equivalent of CLAUDE.md
|
||||
6. GEMINI.md — Gemini equivalent of CLAUDE.md
|
||||
7. README.md — getting started guide
|
||||
8. STATUS.local.md — project status
|
||||
9. .gitignore — standard AIPass ignores
|
||||
10. .claude/settings.json — Claude Code hooks configuration
|
||||
11. hooks/ — directory for user hooks
|
||||
3. CLAUDE.md — project prompt (Claude Code reads this)
|
||||
4. AGENTS.md — Codex equivalent of CLAUDE.md
|
||||
5. GEMINI.md — Gemini equivalent of CLAUDE.md
|
||||
6. README.md — getting started guide
|
||||
7. STATUS.local.md — project status
|
||||
8. .gitignore — standard AIPass ignores
|
||||
9. .claude/settings.json — Claude Code hooks configuration
|
||||
10. hooks/ — directory for user hooks
|
||||
11. src/ — directory where agents live
|
||||
|
||||
Projects are NOT citizens — no .trinity/ directory. Identity lives in the
|
||||
registry JSON. Init is re-runnable: existing files are skipped, not errors.
|
||||
@@ -76,7 +76,8 @@ def _claude_md(name: str) -> str:
|
||||
"aipass init agent <name>\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"This creates a full agent scaffold (`apps/`, `.trinity/`, `.ai_mail.local/`) "
|
||||
"This creates a full agent scaffold inside `src/<name>/` "
|
||||
"(`apps/`, `.trinity/`, `.ai_mail.local/`) "
|
||||
"and registers it in your project registry.\n"
|
||||
"\n"
|
||||
"## Available Commands\n"
|
||||
@@ -201,7 +202,8 @@ def _readme_md(name: str) -> str:
|
||||
" AGENTS.md # Codex instructions\n"
|
||||
" GEMINI.md # Gemini instructions\n"
|
||||
" STATUS.local.md # Project status\n"
|
||||
" <agent_name>/ # Agent directories (created via aipass init agent)\n"
|
||||
" src/ # Agent directories live here\n"
|
||||
" <agent_name>/ # Created via aipass init agent\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"## What is AIPass?\n"
|
||||
@@ -263,30 +265,6 @@ def _global_prompt_md(name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _local_prompt_md(name: str) -> str:
|
||||
"""Generate .aipass/aipass_local_prompt.md — starter skeleton."""
|
||||
return (
|
||||
f"# {name} — Local Prompt\n"
|
||||
"<!-- Injected every turn. Customize for your project. -->\n"
|
||||
"\n"
|
||||
"## Project Identity\n"
|
||||
"\n"
|
||||
f"- **Name:** {name}\n"
|
||||
"- **Purpose:** (describe your project)\n"
|
||||
"- **Status:** New\n"
|
||||
"\n"
|
||||
"## How You Work\n"
|
||||
"\n"
|
||||
"- Read the registry and STATUS.local.md on startup for context\n"
|
||||
"- Check the registry for active agents\n"
|
||||
"\n"
|
||||
"## Key Files\n"
|
||||
"\n"
|
||||
f"- `{name}_REGISTRY.json` — agent registry\n"
|
||||
"- `STATUS.local.md` — current status\n"
|
||||
)
|
||||
|
||||
|
||||
def _gitignore() -> str:
|
||||
"""Generate .gitignore content — standard AIPass ignores."""
|
||||
return (
|
||||
@@ -415,11 +393,6 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
global_prompt_path.write_text(_global_prompt_md(name), encoding="utf-8")
|
||||
created.append(str(global_prompt_path))
|
||||
|
||||
prompt_path = aipass_dir / "aipass_local_prompt.md"
|
||||
if not prompt_path.exists():
|
||||
prompt_path.write_text(_local_prompt_md(name), encoding="utf-8")
|
||||
created.append(str(prompt_path))
|
||||
|
||||
# 3. CLAUDE.md
|
||||
claude_md_path = target / "CLAUDE.md"
|
||||
if not claude_md_path.exists():
|
||||
@@ -480,6 +453,12 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
hooks_dir.mkdir()
|
||||
created.append(str(hooks_dir))
|
||||
|
||||
# 11. src/ directory (where agents live)
|
||||
src_dir = target / "src"
|
||||
if not src_dir.exists():
|
||||
src_dir.mkdir()
|
||||
created.append(str(src_dir))
|
||||
|
||||
return {
|
||||
"registry_id": registry_id,
|
||||
"registry_file": registry_filename,
|
||||
|
||||
@@ -106,15 +106,15 @@ def print_help():
|
||||
|
||||
[green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID
|
||||
[green]2.[/green] [yellow].aipass/aipass_global_prompt.md[/yellow] Global prompt (injected every turn)
|
||||
[green]3.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt skeleton
|
||||
[green]4.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code)
|
||||
[green]5.[/green] [yellow]AGENTS.md[/yellow] Codex instructions
|
||||
[green]6.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions
|
||||
[green]7.[/green] [yellow]README.md[/yellow] Getting started guide
|
||||
[green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status
|
||||
[green]9.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores
|
||||
[green]10.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks
|
||||
[green]11.[/green] [yellow]hooks/[/yellow] User hooks directory"""
|
||||
[green]3.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code)
|
||||
[green]4.[/green] [yellow]AGENTS.md[/yellow] Codex instructions
|
||||
[green]5.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions
|
||||
[green]6.[/green] [yellow]README.md[/yellow] Getting started guide
|
||||
[green]7.[/green] [yellow]STATUS.local.md[/yellow] Project status
|
||||
[green]8.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores
|
||||
[green]9.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks
|
||||
[green]10.[/green] [yellow]hooks/[/yellow] User hooks directory
|
||||
[green]11.[/green] [yellow]src/[/yellow] Agent directories live here"""
|
||||
|
||||
console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||
console.print()
|
||||
@@ -254,7 +254,7 @@ def _handle_init(args: List[str]) -> bool:
|
||||
console.print()
|
||||
console.print("[bold cyan]Next steps:[/bold cyan]")
|
||||
console.print(f" [green]1.[/green] Create your first agent: [yellow]aipass init agent <name>[/yellow]")
|
||||
console.print(f" [green]2.[/green] Start a session: [dim]cd <name>/ && claude[/dim]")
|
||||
console.print(f" [green]2.[/green] Start a session: [dim]cd src/<name>/ && claude[/dim]")
|
||||
console.print(f" [green]3.[/green] Read the docs: [dim]cat README.md[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -273,8 +273,8 @@ def _handle_init_agent(args: List[str]) -> bool:
|
||||
console.print(" [green]drone @cli aipass init agent <name>[/green]")
|
||||
console.print()
|
||||
console.print("[bold cyan]What it does:[/bold cyan]")
|
||||
console.print(" Routes to [yellow]drone @spawn create <name>[/yellow]")
|
||||
console.print(" Creates full agent scaffold (apps/, .trinity/, .ai_mail.local/)")
|
||||
console.print(" Routes to [yellow]drone @spawn create src/<name>[/yellow]")
|
||||
console.print(" Creates full agent scaffold in src/<name>/ (apps/, .trinity/, .ai_mail.local/)")
|
||||
console.print(" Registers agent in the project registry")
|
||||
console.print()
|
||||
console.print("[dim]Commands: init agent, init agent --help[/dim]")
|
||||
@@ -282,11 +282,12 @@ def _handle_init_agent(args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
agent_name = args[0]
|
||||
logger.info("Routing 'init agent %s' to drone @spawn create", agent_name)
|
||||
agent_path = f"src/{agent_name}"
|
||||
logger.info("Routing 'init agent %s' to drone @spawn create %s", agent_name, agent_path)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@spawn", "create", agent_name],
|
||||
["drone", "@spawn", "create", agent_path],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -339,15 +340,15 @@ def _print_init_help():
|
||||
|
||||
[green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID
|
||||
[green]2.[/green] [yellow].aipass/aipass_global_prompt.md[/yellow] Global prompt (injected every turn)
|
||||
[green]3.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt skeleton
|
||||
[green]4.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code)
|
||||
[green]5.[/green] [yellow]AGENTS.md[/yellow] Codex instructions
|
||||
[green]6.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions
|
||||
[green]7.[/green] [yellow]README.md[/yellow] Getting started guide
|
||||
[green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status
|
||||
[green]9.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores
|
||||
[green]10.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks
|
||||
[green]11.[/green] [yellow]hooks/[/yellow] User hooks directory"""
|
||||
[green]3.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code)
|
||||
[green]4.[/green] [yellow]AGENTS.md[/yellow] Codex instructions
|
||||
[green]5.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions
|
||||
[green]6.[/green] [yellow]README.md[/yellow] Getting started guide
|
||||
[green]7.[/green] [yellow]STATUS.local.md[/yellow] Project status
|
||||
[green]8.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores
|
||||
[green]9.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks
|
||||
[green]10.[/green] [yellow]hooks/[/yellow] User hooks directory
|
||||
[green]11.[/green] [yellow]src/[/yellow] Agent directories live here"""
|
||||
|
||||
console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||
console.print()
|
||||
|
||||
@@ -84,7 +84,6 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
expected_files = [
|
||||
target / "DEMO_REGISTRY.json",
|
||||
target / ".aipass" / "aipass_global_prompt.md",
|
||||
target / ".aipass" / "aipass_local_prompt.md",
|
||||
target / "CLAUDE.md",
|
||||
target / "AGENTS.md",
|
||||
target / "GEMINI.md",
|
||||
@@ -96,13 +95,17 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
for f in expected_files:
|
||||
assert f.exists(), f"Expected file not created: {f}"
|
||||
|
||||
# hooks/ is a directory, not a file
|
||||
# hooks/ and src/ are directories, not files
|
||||
assert (target / "hooks").is_dir(), "Expected hooks/ directory"
|
||||
assert (target / "src").is_dir(), "Expected src/ directory"
|
||||
|
||||
# No .trinity/ should be created (projects are not citizens)
|
||||
assert not (target / ".trinity").exists(), ".trinity/ should NOT be created"
|
||||
|
||||
# 10 files + 1 directory = 11 created_files entries
|
||||
# No local prompt at project level (belongs in agent dirs only)
|
||||
assert not (target / ".aipass" / "aipass_local_prompt.md").exists()
|
||||
|
||||
# 9 files + 2 directories = 11 created_files entries
|
||||
assert len(result["created_files"]) == 11
|
||||
|
||||
|
||||
@@ -166,17 +169,14 @@ def test_init_project_no_trinity_created(tmp_path):
|
||||
assert not (target / ".trinity").exists()
|
||||
|
||||
|
||||
def test_init_project_local_prompt_content(tmp_path):
|
||||
"""aipass_local_prompt.md contains the project name heading."""
|
||||
def test_init_project_no_local_prompt(tmp_path):
|
||||
"""Project init does NOT create aipass_local_prompt.md (agent-only file)."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
init_project(target, project_name="epsilon")
|
||||
|
||||
prompt_path = target / ".aipass" / "aipass_local_prompt.md"
|
||||
content = prompt_path.read_text(encoding="utf-8")
|
||||
assert content.startswith("# EPSILON")
|
||||
assert "Local Prompt" in content
|
||||
assert not (target / ".aipass" / "aipass_local_prompt.md").exists()
|
||||
|
||||
|
||||
def test_init_project_claude_md_content(tmp_path):
|
||||
@@ -300,6 +300,7 @@ def test_init_project_readme_md_content(tmp_path):
|
||||
assert "# ALPHA" in content
|
||||
assert "## Quick Start" in content
|
||||
assert "aipass init agent" in content
|
||||
assert "src/" in content
|
||||
|
||||
|
||||
def test_init_project_auto_creates_target_dir(tmp_path):
|
||||
@@ -349,10 +350,6 @@ def test_init_project_skips_existing_optional_files(tmp_path):
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text(
|
||||
"# Custom global\n", encoding="utf-8"
|
||||
)
|
||||
(aipass_dir / "aipass_local_prompt.md").write_text(
|
||||
"# Custom prompt\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
(target / "CLAUDE.md").write_text("# Custom CLAUDE\n", encoding="utf-8")
|
||||
(target / "AGENTS.md").write_text("# Custom AGENTS\n", encoding="utf-8")
|
||||
(target / "GEMINI.md").write_text("# Custom GEMINI\n", encoding="utf-8")
|
||||
@@ -367,17 +364,15 @@ def test_init_project_skips_existing_optional_files(tmp_path):
|
||||
hooks_dir = target / "hooks"
|
||||
hooks_dir.mkdir()
|
||||
|
||||
src_dir = target / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
result = init_project(target, project_name="eta")
|
||||
|
||||
# Only registry should be in created_files (everything else pre-existed)
|
||||
assert len(result["created_files"]) == 1
|
||||
|
||||
# Verify pre-existing files were NOT overwritten
|
||||
prompt_content = (aipass_dir / "aipass_local_prompt.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert prompt_content == "# Custom prompt\n"
|
||||
|
||||
md_content = (target / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert md_content == "# Custom CLAUDE\n"
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,56 +0,0 @@
|
||||
# DAEMON — Branch Context
|
||||
<!-- File: src/aipass/daemon/.aipass/aipass_local_prompt.md — Injected on every prompt when in daemon directory. -->
|
||||
|
||||
Background scheduler and monitoring branch. Cron-triggered tasks, activity reports, action registry, scheduled follow-ups.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
drone @daemon # Introspection — list discovered modules
|
||||
drone @daemon --help # Full help with all commands
|
||||
drone @daemon update # Status digest of daemon activity
|
||||
drone @daemon schedule list # List pending scheduled tasks
|
||||
drone @daemon schedule create "task" --due 7d --to @branch
|
||||
drone @daemon schedule run-due # Fire all due tasks (sends emails)
|
||||
drone @daemon activity # Quick 24h activity summary
|
||||
drone @daemon activity-report # Full detailed report (--json for raw)
|
||||
drone @daemon branch-health BRANCH # Single branch deep dive
|
||||
drone @daemon actions list # Action registry
|
||||
drone @daemon actions set reminder 7d "msg" --to @branch
|
||||
drone @daemon actions set schedule @branch "prompt" daily 04:00
|
||||
```
|
||||
|
||||
Note: The `activity_report` module handles three commands: `activity`, `activity-report`, `branch-health`.
|
||||
|
||||
## Apps Layout
|
||||
|
||||
```
|
||||
apps/
|
||||
├── daemon.py # Entry point — module discovery + command routing
|
||||
├── daemon_wakeup.py # Wakeup / cron trigger
|
||||
├── scheduler_cron.py # Cron scheduler
|
||||
├── modules/ # update, schedule, activity_report, actions, scheduler_ops, wakeup_ops
|
||||
├── handlers/
|
||||
│ ├── actions/ # actions_registry.py
|
||||
│ ├── json/ # json_handler.py
|
||||
│ ├── monitoring/ # activity_collector, memory_health, red_flag_detector, report_generator
|
||||
│ ├── schedule/ # task_registry, assistant_notifier, telegram_notifier
|
||||
│ ├── telegram/ # assistant_chat
|
||||
│ └── update/ # data_loader
|
||||
├── extensions/ # Extension point (empty)
|
||||
└── plugins/ # botfather_reminder, community_rotation, daily_audit, dev_central_monitor, heartbeat
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `activity_report` module shows as `activity_report` in `--help` but its actual commands are `activity`, `activity-report`, `branch-health` — calling `drone @daemon activity_report` fails
|
||||
- `branch-health` expects uppercase branch names from registry; lowercase fails
|
||||
- Secrets path: `~/.secrets/aipass/` (Path.home() / '.secrets' / 'aipass')
|
||||
|
||||
## Memory & Tracking
|
||||
|
||||
- `.trinity/passport.json` — identity
|
||||
- `.trinity/local.json` — session history
|
||||
- `.trinity/observations.json` — collaboration patterns
|
||||
- `dev.local.md` — scratchpad for issues, todos, notes
|
||||
- `DASHBOARD.local.json` — dashboard state
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [
|
||||
"Bash(git reset*)",
|
||||
"Bash(git rebase*)",
|
||||
"Bash(git merge*)",
|
||||
"Bash(git config*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git checkout .*)",
|
||||
"Bash(git restore --staged*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(git clean*)",
|
||||
"Bash(git branch -D*)",
|
||||
"Bash(git stash drop*)",
|
||||
"Bash(git stash clear*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(rm -r *)",
|
||||
"Bash(git checkout -b*)",
|
||||
"Bash(git commit*)",
|
||||
"Bash(git push*)"
|
||||
],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
# DAEMON
|
||||
|
||||
**Purpose:** Cron-triggered task scheduler with plugin system. Routes commands to modules for scheduled tasks, activity reports, action management, and status digests.
|
||||
**Module:** `aipass.daemon`
|
||||
**Created:** 2026-03-07
|
||||
**Citizen Class:** builder
|
||||
**Last Updated:** 2026-04-07
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
||||
|
||||
### What I Do
|
||||
- Route CLI commands to discovered modules (update, schedule, activity_report, actions)
|
||||
- Manage scheduled follow-ups with CRUD operations and due-date processing
|
||||
- Generate activity reports across all branches (24h summary, detailed, per-branch)
|
||||
- Run action registry (list, toggle, set reminder/schedule, migrate plugins)
|
||||
- Auto-discover and dispatch plugins (community_rotation, daily_audit, heartbeat)
|
||||
- Detect red flags (code changes without memory updates, stale branches)
|
||||
- Produce status digests (inbox, actionable items, escalations)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
daemon/
|
||||
├── __init__.py
|
||||
├── README.md
|
||||
├── DASHBOARD.local.json
|
||||
├── apps/
|
||||
│ ├── daemon.py # Entry point (CLI) — module discovery + command routing
|
||||
│ ├── daemon_wakeup.py # Wakeup / cron trigger
|
||||
│ ├── scheduler_cron.py # Cron scheduler
|
||||
│ ├── modules/
|
||||
│ │ ├── update.py # Status digest module — summarizes DAEMON activity
|
||||
│ │ ├── schedule.py # Scheduled follow-ups — fire-and-forget task management
|
||||
│ │ ├── activity_report.py # Branch activity report generator
|
||||
│ │ ├── actions.py # Action registry CLI — list, toggle, info, reminders
|
||||
│ │ ├── scheduler_ops.py # Scheduler cron operations facade
|
||||
│ │ └── wakeup_ops.py # Wake-up cron operations facade
|
||||
│ ├── handlers/
|
||||
│ │ ├── actions/
|
||||
│ │ │ └── actions_registry.py # Action registry implementation
|
||||
│ │ ├── json/
|
||||
│ │ │ └── json_handler.py # JSON data operations
|
||||
│ │ ├── monitoring/
|
||||
│ │ │ ├── activity_collector.py # Collects branch activity data
|
||||
│ │ │ ├── memory_health.py # Memory health checks
|
||||
│ │ │ └── red_flag_detector.py # Detects anomalies / red flags
|
||||
│ │ ├── schedule/
|
||||
│ │ │ ├── task_registry.py # Task registry for scheduled items
|
||||
│ │ │ └── .archive/ # assistant_notifier, telegram_notifier (archived)
|
||||
│ │ ├── telegram/ # ARCHIVED — moving to skills system
|
||||
│ │ │ └── .archive/ # assistant_chat (archived)
|
||||
│ │ └── update/
|
||||
│ │ └── data_loader.py # Data loading for status digests
|
||||
│ ├── extensions/ # Extension point for additional capabilities
|
||||
│ ├── json_templates/ # JSON template definitions
|
||||
│ └── plugins/
|
||||
│ ├── community_rotation.py # Community rotation plugin
|
||||
│ ├── daily_audit.py # Daily audit plugin
|
||||
│ ├── heartbeat.py # Heartbeat / liveness plugin
|
||||
│ └── .archive/ # botfather_reminder, devpulse_monitor (archived)
|
||||
├── daemon_json/ # JSON tracking data
|
||||
├── docs/ # Documentation
|
||||
├── dropbox/ # Incoming file drops
|
||||
├── logs/ # Prax log output
|
||||
├── tools/ # Branch verification utilities
|
||||
└── tests/ # Test suite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands / Usage
|
||||
|
||||
```bash
|
||||
drone @daemon # Show discovered modules (introspection)
|
||||
drone @daemon --help # Rich-formatted help with all commands
|
||||
drone @daemon --version # Print version
|
||||
|
||||
drone @daemon update # Status digest — inbox, session info, escalations (partial — reads stale data paths)
|
||||
drone @daemon schedule list # List pending scheduled tasks
|
||||
drone @daemon schedule create "task" --due 7d --to @branch
|
||||
drone @daemon schedule run-due # Fire all due tasks (sends emails)
|
||||
drone @daemon activity # Quick 24h activity summary
|
||||
drone @daemon activity-report # Full detailed report (--json for raw)
|
||||
drone @daemon branch-health BRANCH # Single branch deep dive
|
||||
drone @daemon actions list # Action registry
|
||||
drone @daemon actions <id> on/off # Toggle action
|
||||
drone @daemon actions set reminder 7d "msg" --to @branch
|
||||
drone @daemon actions set schedule @branch "prompt" daily 04:00
|
||||
```
|
||||
|
||||
Each module accepts `--help` for module-specific usage:
|
||||
```bash
|
||||
drone @daemon <command> --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description | Status |
|
||||
|--------|-------------|--------|
|
||||
| `update` | Status digest of DAEMON activity | *(partial)* — reads inbox/sessions but data_loader paths return empty |
|
||||
| `schedule` | Fire-and-forget scheduled follow-ups and task management | Operational |
|
||||
| `activity_report` | Branch activity reports: `activity`, `activity-report`, `branch-health` | Operational |
|
||||
| `actions` | Action registry CLI — list, toggle, info, set reminder, set schedule, migrate | Operational |
|
||||
| `scheduler_ops` | Scheduler cron operations facade for scheduler_cron.py | Operational |
|
||||
| `wakeup_ops` | Wake-up cron operations facade for daemon_wakeup.py | Operational |
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
- `rich` -- Console output and formatted display
|
||||
- Python stdlib (`sys`, `typing`, `logging`)
|
||||
|
||||
### Provides To
|
||||
- All modules — background task scheduling, activity monitoring, action tracking
|
||||
- Plugins — extensible plugin system for recurring tasks (community_rotation, daily_audit, heartbeat)
|
||||
- Note: Telegram handlers archived — moving to skills system. See `apps/handlers/telegram/.archive/`
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Target | Schedule | Status |
|
||||
|--------|--------|----------|--------|
|
||||
| `community_rotation` | @rotating | every 4h | Operational — requires AIPASS_WAKE_SCRIPT env var |
|
||||
| `daily_audit` | @seed | daily 04:00 | *(not operational)* — targets @seed (renamed to @seedgo) |
|
||||
| `heartbeat` | @vera | every 4h | *(not operational)* — @vera not in branch registry |
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `update` command shows empty data (0 sessions, no focus) — data_loader reads from different paths than .trinity/local.json
|
||||
- `daily_audit` plugin targets `@seed` which was renamed to `@seedgo`
|
||||
- `heartbeat` plugin targets `@vera` which is not registered in the branch registry
|
||||
- All plugins require `AIPASS_WAKE_SCRIPT` env var to dispatch — without it, plugins discover but can't execute
|
||||
- `drone @daemon activity_report` (underscore) fails — use `activity`, `activity-report`, or `branch-health` instead
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
- **Passport:** `.trinity/passport.json`
|
||||
- **Session History:** `.trinity/local.json`
|
||||
- **Observations:** `.trinity/observations.json`
|
||||
- **Branch Prompt:** `.aipass/branch_system_prompt.md`
|
||||
|
||||
---
|
||||
|
||||
## Test Suite
|
||||
|
||||
- **387 tests** across 16 test files
|
||||
- 8/8 modules covered, 43/51 public functions tested
|
||||
- Seedgo audit: **100%** across all standards
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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.
|
||||
@@ -1 +0,0 @@
|
||||
# Apps package - Branch application modules and handlers
|
||||
@@ -1,207 +0,0 @@
|
||||
# =================== 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
|
||||
|
||||
def _header(text):
|
||||
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
|
||||
console.print(f"[bold cyan] {text}[/bold cyan]")
|
||||
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
from aipass.daemon.apps.modules import update, schedule, activity_report, actions
|
||||
|
||||
def get_modules() -> List[Any]:
|
||||
"""
|
||||
Return list of known modules that implement handle_command().
|
||||
|
||||
Returns:
|
||||
List of module objects with handle_command function
|
||||
"""
|
||||
modules = []
|
||||
for mod in [update, schedule, activity_report, actions]:
|
||||
if hasattr(mod, 'handle_command'):
|
||||
modules.append(mod)
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""
|
||||
Route command to appropriate module
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., 'create', 'update', 'list')
|
||||
args: Additional arguments
|
||||
modules: List of discovered modules
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[DAEMON] Module {module.__name__} error: {e}")
|
||||
|
||||
return False
|
||||
|
||||
# =============================================================================
|
||||
# INTROSPECTION DISPLAY
|
||||
# =============================================================================
|
||||
|
||||
def print_introspection(modules: List[Any]):
|
||||
"""Display discovered modules when run without arguments"""
|
||||
console.print()
|
||||
console.print("[bold cyan]DAEMON - Branch Management System[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Module orchestration[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print(f"[yellow]Modules:[/yellow] {len(modules)}")
|
||||
console.print()
|
||||
|
||||
if modules:
|
||||
for module in modules:
|
||||
module_name = module.__name__.split('.')[-1]
|
||||
# Get first line of docstring
|
||||
description = "No description"
|
||||
if module.__doc__:
|
||||
description = module.__doc__.strip().split('\n')[0]
|
||||
console.print(f" [cyan]*[/cyan] {module_name:20} [dim]{description}[/dim]")
|
||||
else:
|
||||
console.print(" [dim]No modules discovered[/dim]")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Run 'daemon --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRONE COMPLIANCE - HELP SYSTEM
|
||||
# =============================================================================
|
||||
|
||||
def print_help(modules: List[Any]):
|
||||
"""Display Rich-formatted help"""
|
||||
console.print()
|
||||
_header("DAEMON - Branch Management System")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Module orchestration[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]daemon <command> [args...][/dim]")
|
||||
console.print(" [dim]daemon --help[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
# Show actual routable commands, not module names
|
||||
_COMMAND_HELP = [
|
||||
("update", "Returns digest of DAEMON activity for check-ins."),
|
||||
("schedule", "CLI interface for fire-and-forget scheduled follow-ups."),
|
||||
("activity", "Quick 24-hour activity summary."),
|
||||
("activity-report", "Full detailed activity report (--json for raw)."),
|
||||
("branch-health", "Single branch deep dive (e.g., branch-health DAEMON)."),
|
||||
("actions", "CLI interface for the numbered action registry."),
|
||||
]
|
||||
|
||||
for cmd_name, desc in _COMMAND_HELP:
|
||||
console.print(f" [green]{cmd_name:20}[/green] [dim]{desc}[/dim]")
|
||||
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold]TIP:[/bold] For module-specific help:")
|
||||
console.print(" [dim]daemon <command> --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help"""
|
||||
|
||||
# Get available modules
|
||||
modules = get_modules()
|
||||
|
||||
# Parse arguments
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run with no arguments
|
||||
if len(args) == 0:
|
||||
print_introspection(modules)
|
||||
return 0
|
||||
|
||||
# Version flag
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("DAEMON v1.0.0")
|
||||
return 0
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help(modules)
|
||||
return 0
|
||||
|
||||
# Extract command and remaining args
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
json_handler.log_operation("daemon_command", {"command": command})
|
||||
|
||||
# Route to modules
|
||||
if route_command(command, remaining_args, modules):
|
||||
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)
|
||||
@@ -1,247 +0,0 @@
|
||||
# =================== 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
|
||||
import fcntl
|
||||
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
|
||||
|
||||
# =============================================
|
||||
# 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("daemon_wakeup Module")
|
||||
console.print("Cron trigger for daemon wake-up inbox checking and reporting")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" modules/")
|
||||
console.print(" - wakeup_ops.py (notifications archived — Telegram removed)")
|
||||
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 ['--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)
|
||||
lock_fd = open(LOCK_FILE, "w")
|
||||
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")
|
||||
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)
|
||||
@@ -1 +0,0 @@
|
||||
# Extensions package - Drop-in extensions for branch functionality
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Daemon handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "aipass.daemon"
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""
|
||||
Walk the stack to find the actual file that triggered this import.
|
||||
|
||||
Skips:
|
||||
- This file (handlers/__init__.py)
|
||||
- Python's importlib internals
|
||||
- Frozen modules
|
||||
|
||||
Returns tuple: (file_path, import_line) or (None, None)
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
|
||||
# Skip this file
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
|
||||
# Skip Python internals
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
|
||||
# Found a real file - try to get the import line
|
||||
import_line = None
|
||||
if frame_info.code_context:
|
||||
import_line = frame_info.code_context[0].strip()
|
||||
|
||||
return str(Path(filename).resolve()), import_line
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass":
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""
|
||||
Block cross-branch handler imports.
|
||||
|
||||
Only code from within the 'daemon' branch can import these handlers.
|
||||
External branches must use aipass.daemon.apps.modules instead.
|
||||
"""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
# DEBUG: Print what we found
|
||||
import os
|
||||
if os.environ.get("AIPASS_DEBUG_GUARD"):
|
||||
import sys
|
||||
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
|
||||
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
|
||||
|
||||
if caller_file is None:
|
||||
# Can't determine caller from real files
|
||||
# Check if we're being run from command line (external)
|
||||
# by looking at the raw stack for <string> or <stdin>
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
return # Allow command-line Python through
|
||||
return # Allow if truly can't determine
|
||||
|
||||
# Check if caller is from our branch
|
||||
# MY_BRANCH is "aipass.daemon" (dotted), but filesystem uses "/aipass/daemon/"
|
||||
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
|
||||
if branch_path in caller_file:
|
||||
return # Same branch, allowed
|
||||
|
||||
# External caller - block access
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'='*60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'='*60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" Example:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.logger import logger\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
@@ -1,341 +0,0 @@
|
||||
# =================== 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
|
||||
@@ -1,542 +0,0 @@
|
||||
# =================== 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
|
||||
@@ -1 +0,0 @@
|
||||
"""JSON Handlers - Universal JSON operations for daemon branch"""
|
||||
@@ -1,252 +0,0 @@
|
||||
# =================== 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()
|
||||
@@ -1,56 +0,0 @@
|
||||
# ===================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',
|
||||
]
|
||||
@@ -1,335 +0,0 @@
|
||||
# =================== 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']}")
|
||||
@@ -1,421 +0,0 @@
|
||||
# =================== 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}")
|
||||
@@ -1,371 +0,0 @@
|
||||
# =================== 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.")
|
||||
@@ -1,497 +0,0 @@
|
||||
# =================== 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"]
|
||||
activity = data["activity_data"]
|
||||
health_data = data["memory_health"]
|
||||
|
||||
lines: List[str] = []
|
||||
|
||||
# Header
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
lines.append(_box_header(f"BRANCH ACTIVITY REPORT - {timestamp}\nTime window: Last {since_hours:.0f} hours"))
|
||||
|
||||
# Summary section
|
||||
total_branches = red_flags.get("total_branches", 0)
|
||||
active_branches = red_flags.get("ok", 0)
|
||||
red_flag_count = red_flags.get("red_flags", 0)
|
||||
no_activity_count = red_flags.get("no_activity", 0)
|
||||
error_count = red_flags.get("errors", 0)
|
||||
|
||||
# Count memory health statuses
|
||||
health_ok = sum(1 for h in health_data.values() if h.get("overall_status") == "OK")
|
||||
health_warning = sum(1 for h in health_data.values() if h.get("overall_status") == "WARNING")
|
||||
health_red = sum(1 for h in health_data.values() if h.get("overall_status") == "RED")
|
||||
|
||||
lines.append(_section_header("SUMMARY"))
|
||||
lines.append(f"Active branches: {active_branches}/{total_branches}")
|
||||
lines.append(f"RED FLAGS: {red_flag_count}")
|
||||
lines.append(f"Memory health: {health_ok} OK, {health_warning} warning, {health_red} red")
|
||||
if error_count > 0:
|
||||
lines.append(f"Scan errors: {error_count}")
|
||||
|
||||
# Red Flags section
|
||||
lines.append(_section_header("RED FLAGS (requires attention)"))
|
||||
violations = red_flags.get("violations", [])
|
||||
if violations:
|
||||
for v in violations:
|
||||
branch = v.get("branch_name", "UNKNOWN")
|
||||
reason = v.get("reason", "Unknown reason")
|
||||
code_count = v.get("code_change_count", 0)
|
||||
lines.append(f" {SYMBOL_RED} {branch}")
|
||||
lines.append(f" Reason: {reason}")
|
||||
lines.append(f" Code changes: {code_count} files")
|
||||
else:
|
||||
lines.append(" [None - all active branches updated memories]")
|
||||
|
||||
# Branch Status section
|
||||
lines.append(_section_header("BRANCH STATUS"))
|
||||
|
||||
all_branches = red_flags.get("all_branches", [])
|
||||
|
||||
if verbosity != "brief":
|
||||
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,
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# ===================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'
|
||||
]
|
||||
@@ -1,605 +0,0 @@
|
||||
# =================== 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()
|
||||
@@ -1 +0,0 @@
|
||||
"""Telegram handlers - ARCHIVED. See .archive/ directory. Telegram moving to skills system."""
|
||||
@@ -1 +0,0 @@
|
||||
# Update handlers package
|
||||
@@ -1,110 +0,0 @@
|
||||
# =================== 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()]
|
||||
@@ -1 +0,0 @@
|
||||
# JSON Templates package - Default JSON file templates
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"version": "1.0.0",
|
||||
"timestamp": "{{CURRENT_DATE}}",
|
||||
"config": {
|
||||
"auto_save": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"created": "{{CURRENT_DATE}}",
|
||||
"last_updated": "{{CURRENT_DATE}}",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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.
|
||||
@@ -1 +0,0 @@
|
||||
# Modules package - Branch-specific functionality modules
|
||||
@@ -1,541 +0,0 @@
|
||||
# =================== 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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "actions"
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("actions Module")
|
||||
console.print("CLI interface for the numbered action registry (DPLAN-043)")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/actions/")
|
||||
console.print(" - actions_registry.py (list_actions, get_action, toggle_action, delete_action, create_action, migrate_plugins, next_due_str — registry CRUD)")
|
||||
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} "
|
||||
f"{'TARGET':<16} {'SCHEDULE':<20} {'NEXT DUE':<16}"
|
||||
)
|
||||
console.print(" " + "-" * 96)
|
||||
|
||||
for action in actions:
|
||||
action_id = action.get("id", "????")
|
||||
enabled = "[green]ON[/green] " if action.get("enabled") else "[red]OFF[/red]"
|
||||
name = action.get("name", "")[:22]
|
||||
action_type = action.get("type", "")[:8]
|
||||
target = action.get("target_branch", "")[:14]
|
||||
|
||||
schedule_str = _format_schedule(action)
|
||||
next_due = next_due_str(action)
|
||||
|
||||
console.print(
|
||||
f" {action_id:<6} {enabled:<4} {name:<24} {action_type:<10} "
|
||||
f" {target:<16} {schedule_str:<20} {next_due:<16}"
|
||||
)
|
||||
|
||||
console.print()
|
||||
enabled_count = sum(1 for a in actions if a.get("enabled"))
|
||||
console.print(f" [dim]Total: {len(actions)} actions ({enabled_count} enabled)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_action_detail(action: dict) -> None:
|
||||
"""Display detailed view of a single action."""
|
||||
console.print()
|
||||
_header(f"Action {action['id']}: {action['name']}")
|
||||
console.print()
|
||||
|
||||
fields = [
|
||||
("ID", action.get("id")),
|
||||
("Name", action.get("name")),
|
||||
("Type", action.get("type")),
|
||||
("Enabled", "[green]ON[/green]" if action.get("enabled") else "[red]OFF[/red]"),
|
||||
("Schedule", action.get("schedule_type")),
|
||||
("Time", action.get("time")),
|
||||
("Interval", f"{action.get('interval_minutes')}m" if action.get("interval_minutes") else None),
|
||||
("Due Date", action.get("due_date")),
|
||||
("Target", action.get("target_branch")),
|
||||
("Fresh", action.get("fresh")),
|
||||
("Max Turns", action.get("max_turns")),
|
||||
("Self Dispatch", action.get("self_dispatch")),
|
||||
("Plugin File", action.get("plugin_file")),
|
||||
("Last Run", action.get("last_run", "never")[:19] if action.get("last_run") else "never"),
|
||||
("Next Run", next_due_str(action)),
|
||||
("Created", action.get("created", "")[:19]),
|
||||
("Completed", action.get("completed")),
|
||||
]
|
||||
|
||||
for label, value in fields:
|
||||
if value is None:
|
||||
continue
|
||||
console.print(f" [cyan]{label:<16}[/cyan] {value}")
|
||||
|
||||
# Show prompt (truncated for readability)
|
||||
prompt = action.get("prompt", "")
|
||||
if prompt:
|
||||
console.print()
|
||||
console.print(" [cyan]Prompt:[/cyan]")
|
||||
# Show first 200 chars
|
||||
display_prompt = prompt[:200]
|
||||
if len(prompt) > 200:
|
||||
display_prompt += "..."
|
||||
for line in display_prompt.split("\n"):
|
||||
console.print(f" [dim]{line}[/dim]")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display help using Rich formatted output."""
|
||||
console.print()
|
||||
_header("Actions -- Numbered Action Registry")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon actions list")
|
||||
console.print(" drone @daemon actions <id> info")
|
||||
console.print(" drone @daemon actions <id> on")
|
||||
console.print(" drone @daemon actions <id> off")
|
||||
console.print(' drone @daemon actions set reminder <date> "message" [--to @branch]')
|
||||
console.print(' drone @daemon actions set schedule @branch "prompt" <type> [time]')
|
||||
console.print(" drone @daemon actions migrate")
|
||||
console.print(" drone @daemon actions delete <id>")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" list List all registered actions with status")
|
||||
console.print(" <id> info Show detailed view of a single action")
|
||||
console.print(" <id> on Enable an action")
|
||||
console.print(" <id> off Disable an action")
|
||||
console.print(" set Create a new reminder or schedule")
|
||||
console.print(" migrate Import existing plugins into registry")
|
||||
console.print(" delete <id> Remove an action from the registry")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]SET REMINDER:[/yellow]")
|
||||
console.print(' set reminder 2026-03-11 "Check VERA progress"')
|
||||
console.print(' set reminder 7d "Follow up on PR review" --to @flow')
|
||||
console.print(" [dim]Date formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]SET SCHEDULE:[/yellow]")
|
||||
console.print(' set schedule @seedgo "Run audit" daily 04:00')
|
||||
console.print(' set schedule @vera "Heartbeat" interval 240')
|
||||
console.print(' set schedule @flow "Check plans" hourly 30')
|
||||
console.print(" [dim]Types: daily HH:MM, hourly MM, interval MINUTES[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" actions list # See all actions")
|
||||
console.print(" actions 0003 off # Disable action 3")
|
||||
console.print(" actions 0003 on # Re-enable it")
|
||||
console.print(' actions set reminder 2026-03-11 "check VERA" # One-shot reminder')
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# SUBCOMMAND HANDLERS
|
||||
# =============================================
|
||||
|
||||
def _handle_list(_args: List[str]) -> bool:
|
||||
"""Handle 'actions list' subcommand."""
|
||||
actions = list_actions()
|
||||
_print_actions_table(actions)
|
||||
return True
|
||||
|
||||
|
||||
def _handle_toggle(action_id: str, enable: bool) -> bool:
|
||||
"""Handle 'actions <id> on/off' subcommand."""
|
||||
action = get_action(action_id)
|
||||
if action is None:
|
||||
_error(f"Action not found: {action_id}")
|
||||
return True # Error displayed
|
||||
|
||||
toggle_action(action_id, enable)
|
||||
state = "enabled" if enable else "disabled"
|
||||
_success(f"Action {action_id} ({action['name']}) {state}")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_info(action_id: str) -> bool:
|
||||
"""Handle 'actions <id> info' subcommand."""
|
||||
action = get_action(action_id)
|
||||
if action is None:
|
||||
_error(f"Action not found: {action_id}")
|
||||
return True # Error displayed
|
||||
|
||||
_print_action_detail(action)
|
||||
return True
|
||||
|
||||
|
||||
def _handle_set_reminder(args: List[str]) -> bool:
|
||||
"""Handle 'actions set reminder <date> "message" [--to @branch]'."""
|
||||
if len(args) < 2:
|
||||
_error('Usage: actions set reminder <date> "message" [--to @branch]')
|
||||
return 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()
|
||||
return True
|
||||
|
||||
|
||||
def _handle_set_schedule(args: List[str]) -> bool:
|
||||
"""Handle 'actions set schedule @branch "prompt" <type> [time_spec]'."""
|
||||
if len(args) < 3:
|
||||
_error('Usage: actions set schedule @branch "prompt" <daily|hourly|interval> [time_spec]')
|
||||
return True # Error displayed
|
||||
|
||||
target_branch = args[0]
|
||||
prompt = args[1]
|
||||
schedule_type = args[2]
|
||||
|
||||
time_val = None
|
||||
interval_minutes = None
|
||||
|
||||
if schedule_type not in ("daily", "hourly", "interval"):
|
||||
_error(f"Unknown schedule type: {schedule_type}")
|
||||
console.print("[dim]Valid types: daily, hourly, interval[/dim]")
|
||||
return True # Error displayed
|
||||
|
||||
if len(args) < 4:
|
||||
_error(f"{schedule_type.title()} schedule requires a time/value argument")
|
||||
return True # Error displayed
|
||||
|
||||
if schedule_type in ("daily", "hourly"):
|
||||
time_val = args[3]
|
||||
else:
|
||||
try:
|
||||
interval_minutes = int(args[3])
|
||||
except ValueError:
|
||||
logger.warning("Invalid interval minutes value: %s", args[3])
|
||||
_error(f"Invalid interval minutes: {args[3]}")
|
||||
return True # Error displayed
|
||||
|
||||
# Generate a name from the prompt
|
||||
name = prompt[:50].replace(" ", "_").lower()
|
||||
|
||||
action = create_action(
|
||||
name=name,
|
||||
action_type="schedule",
|
||||
schedule_type=schedule_type,
|
||||
target_branch=target_branch,
|
||||
prompt=prompt,
|
||||
time=time_val,
|
||||
interval_minutes=interval_minutes,
|
||||
fresh=True,
|
||||
max_turns=50,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
_success(f"Schedule created: {action['id']}")
|
||||
console.print(f" [dim]Name:[/dim] {action['name']}")
|
||||
console.print(f" [dim]Target:[/dim] {target_branch}")
|
||||
console.print(f" [dim]Type:[/dim] {schedule_type}")
|
||||
if time_val:
|
||||
console.print(f" [dim]Time:[/dim] {time_val}")
|
||||
if interval_minutes:
|
||||
console.print(f" [dim]Every:[/dim] {interval_minutes} minutes")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
|
||||
def _handle_migrate(_args: List[str]) -> bool:
|
||||
"""Handle 'actions migrate' -- import plugins into registry."""
|
||||
console.print()
|
||||
console.print("[dim]Scanning plugins/ for unregistered plugins...[/dim]")
|
||||
|
||||
count = migrate_plugins()
|
||||
|
||||
if count > 0:
|
||||
_success(f"Migrated {count} plugin(s) into the action registry")
|
||||
else:
|
||||
console.print("[dim]All plugins already registered (or none found).[/dim]")
|
||||
|
||||
# Show the updated list
|
||||
actions = list_actions()
|
||||
_print_actions_table(actions)
|
||||
return True
|
||||
|
||||
|
||||
def _handle_delete(args: List[str]) -> bool:
|
||||
"""Handle 'actions delete <id>'."""
|
||||
if not args:
|
||||
_error("Action ID required: actions delete <id>")
|
||||
return 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']}")
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# DATE PARSING
|
||||
# =============================================
|
||||
|
||||
def _parse_date(date_str: str) -> str:
|
||||
"""
|
||||
Parse a date string into ISO format.
|
||||
|
||||
Supports: YYYY-MM-DD, 1d, 7d, 1w, 2w
|
||||
|
||||
Returns:
|
||||
ISO date string or empty string on failure.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
date_str = date_str.strip()
|
||||
|
||||
# Relative dates
|
||||
if date_str.endswith("d"):
|
||||
try:
|
||||
days = int(date_str[:-1])
|
||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning("Invalid relative day format: %s", date_str)
|
||||
return ""
|
||||
elif date_str.endswith("w"):
|
||||
try:
|
||||
weeks = int(date_str[:-1])
|
||||
return (datetime.now() + timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning("Invalid relative week format: %s", date_str)
|
||||
return ""
|
||||
|
||||
# ISO date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return date_str
|
||||
except ValueError:
|
||||
logger.warning("Invalid ISO date format: %s", date_str)
|
||||
return ""
|
||||
|
||||
|
||||
# =============================================
|
||||
# ORCHESTRATION
|
||||
# =============================================
|
||||
|
||||
def _route_set_subcommand(args: List[str]) -> bool:
|
||||
"""Route 'actions set reminder ...' / 'actions set schedule ...'."""
|
||||
if len(args) < 2:
|
||||
_error("Usage: actions set <reminder|schedule> ...")
|
||||
return True # Error displayed
|
||||
set_type = args[1]
|
||||
if set_type == "reminder":
|
||||
return _handle_set_reminder(args[2:])
|
||||
if set_type == "schedule":
|
||||
return _handle_set_schedule(args[2:])
|
||||
_error(f"Unknown set type: {set_type}. Use 'reminder' or 'schedule'.")
|
||||
return True # Error displayed
|
||||
|
||||
|
||||
def _route_action_id(action_id: str, args: List[str]) -> bool:
|
||||
"""Route 'actions <4-digit-id> [on|off|info]'."""
|
||||
if len(args) < 2:
|
||||
return _handle_info(action_id)
|
||||
sub_action = args[1]
|
||||
if sub_action == "on":
|
||||
return _handle_toggle(action_id, True)
|
||||
if sub_action == "off":
|
||||
return _handle_toggle(action_id, False)
|
||||
if sub_action == "info":
|
||||
return _handle_info(action_id)
|
||||
_error(f"Unknown action command: {sub_action}. Use 'on', 'off', or 'info'.")
|
||||
return True # Error displayed
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle 'actions' command and route to subcommands.
|
||||
|
||||
Args:
|
||||
command: Command name (should be 'actions')
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if handled, False otherwise
|
||||
"""
|
||||
if command != "actions":
|
||||
return False
|
||||
|
||||
try:
|
||||
# No args -- introspection gate
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
# Help flag
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
json_handler.log_operation("actions_command", {"subcommand": args[0] if args else "introspection"})
|
||||
|
||||
# Named subcommands
|
||||
if subcommand == "list":
|
||||
return _handle_list(args[1:])
|
||||
if subcommand == "migrate":
|
||||
return _handle_migrate(args[1:])
|
||||
if subcommand == "delete":
|
||||
return _handle_delete(args[1:])
|
||||
if subcommand == "set":
|
||||
return _route_set_subcommand(args)
|
||||
|
||||
# Check if first arg is an action ID (4-digit numeric)
|
||||
if subcommand.isdigit() and len(subcommand) == 4:
|
||||
return _route_action_id(subcommand, args)
|
||||
|
||||
_error(f"Unknown subcommand: {subcommand}")
|
||||
console.print("[dim]Run 'actions --help' for available commands[/dim]")
|
||||
return True # Command was handled (error displayed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[actions] Error in actions command: %s", e, exc_info=True)
|
||||
_error(f"Error: {e}")
|
||||
return True # Error displayed
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN ENTRY
|
||||
# =============================================
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for direct execution."""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args or args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return
|
||||
|
||||
handle_command('actions', args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,313 +0,0 @@
|
||||
# =================== 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 Dict, Any, 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("activity_report Module")
|
||||
console.print("Branch activity report generator — monitors file changes, memory health, and presence violations")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/monitoring/")
|
||||
console.print(" - report_generator.py (generate_activity_report, generate_branch_report, get_json_report — report generation and JSON output)")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# COMMAND INTEGRATION (AUTO-DISCOVERY)
|
||||
# =============================================
|
||||
|
||||
def _print_activity_help() -> None:
|
||||
"""Display help for the activity command."""
|
||||
console.print()
|
||||
console.print("=" * 60)
|
||||
console.print("ACTIVITY - Quick Activity Summary")
|
||||
console.print("=" * 60)
|
||||
console.print()
|
||||
console.print("USAGE:")
|
||||
console.print(" drone @daemon activity")
|
||||
console.print(" daemon activity")
|
||||
console.print(" daemon activity --hours 48")
|
||||
console.print()
|
||||
console.print("DESCRIPTION:")
|
||||
console.print(" Quick 24-hour activity summary (default).")
|
||||
console.print(" Shows branch status, red flags, and recommendations.")
|
||||
console.print()
|
||||
console.print("OPTIONS:")
|
||||
console.print(" --hours N, -t N Time window in hours (default: 24)")
|
||||
console.print(" --help, -h Show this help message")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_activity_report_help() -> None:
|
||||
"""Display help for the activity-report command."""
|
||||
console.print()
|
||||
console.print("=" * 60)
|
||||
console.print("ACTIVITY-REPORT - Full Detailed Report")
|
||||
console.print("=" * 60)
|
||||
console.print()
|
||||
console.print("USAGE:")
|
||||
console.print(" drone @daemon activity-report")
|
||||
console.print(" daemon activity-report")
|
||||
console.print(" daemon activity-report --hours 48")
|
||||
console.print(" daemon activity-report --json")
|
||||
console.print()
|
||||
console.print("DESCRIPTION:")
|
||||
console.print(" Full detailed activity report with file-level changes.")
|
||||
console.print(" Includes per-branch breakdown and complete recommendations.")
|
||||
console.print()
|
||||
console.print("OPTIONS:")
|
||||
console.print(" --hours N, -t N Time window in hours (default: 24)")
|
||||
console.print(" --json, -j Output raw JSON data")
|
||||
console.print(" --help, -h Show this help message")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_branch_health_help() -> None:
|
||||
"""Display help for the branch-health command."""
|
||||
console.print()
|
||||
console.print("=" * 60)
|
||||
console.print("BRANCH-HEALTH - Single Branch Deep Dive")
|
||||
console.print("=" * 60)
|
||||
console.print()
|
||||
console.print("USAGE:")
|
||||
console.print(" drone @daemon branch-health DRONE")
|
||||
console.print(" daemon branch-health FLOW")
|
||||
console.print(" daemon branch-health SEEDGO --hours 48")
|
||||
console.print()
|
||||
console.print("DESCRIPTION:")
|
||||
console.print(" Deep dive report for a single branch.")
|
||||
console.print(" Shows all file changes, memory health, and specific recommendations.")
|
||||
console.print()
|
||||
console.print("OPTIONS:")
|
||||
console.print(" <branch_name> Required - branch name (e.g., DRONE, FLOW, SEEDGO)")
|
||||
console.print(" --hours N, -t N Time window in hours (default: 24)")
|
||||
console.print(" --help, -h Show this help message")
|
||||
console.print()
|
||||
|
||||
|
||||
def _parse_hours_arg(args: List[str]) -> float:
|
||||
"""
|
||||
Extract --hours or -t argument from args list.
|
||||
|
||||
Args:
|
||||
args: Command arguments list.
|
||||
|
||||
Returns:
|
||||
Hours value (default 24 if not specified).
|
||||
"""
|
||||
hours = 24.0
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] in ('--hours', '-t') and i + 1 < len(args):
|
||||
try:
|
||||
hours = float(args[i + 1])
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid --hours value '%s': %s", args[i + 1], e)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return hours
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle activity monitoring commands via auto-discovery.
|
||||
|
||||
Routes commands to appropriate report generation functions.
|
||||
|
||||
Commands:
|
||||
- activity: Quick activity summary (verbosity="normal", last 24h)
|
||||
- activity-report: Full detailed report (verbosity="detailed")
|
||||
- branch-health <branch>: Single branch deep dive
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., 'update', 'activity-report', 'branch-health')
|
||||
args: Additional arguments (e.g., ['--hours', '48'])
|
||||
|
||||
Returns:
|
||||
True if command was handled, False if not our command.
|
||||
"""
|
||||
# Handle 'activity_report' as alias — help shows module name, users expect it to work
|
||||
if command == "activity_report":
|
||||
if args and args[0] in ('--help', '-h', 'help'):
|
||||
print_introspection()
|
||||
return True
|
||||
json_handler.log_operation("activity_report", {"command": command})
|
||||
hours = _parse_hours_arg(args)
|
||||
report = generate_activity_report(since_hours=hours, verbosity="normal")
|
||||
console.print(report)
|
||||
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)
|
||||
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)
|
||||
return True
|
||||
|
||||
# Handle 'branch-health' command - requires branch name arg
|
||||
if command == "branch-health":
|
||||
return _handle_branch_health(args)
|
||||
|
||||
# Not our command
|
||||
return False
|
||||
|
||||
|
||||
def _extract_branch_name(args: List[str]) -> str | None:
|
||||
"""Extract the first non-flag argument as the branch name."""
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] in ('--hours', '-t') and i + 1 < len(args):
|
||||
i += 2
|
||||
elif args[i].startswith('-'):
|
||||
i += 1
|
||||
else:
|
||||
return args[i]
|
||||
return None
|
||||
|
||||
|
||||
def _handle_branch_health(args: List[str]) -> bool:
|
||||
"""Handle 'branch-health [branch]' command. No args = all branches summary."""
|
||||
if not args:
|
||||
json_handler.log_operation("branch_health_all", {"command": "branch-health"})
|
||||
report = generate_activity_report(since_hours=24, verbosity="normal")
|
||||
console.print(report)
|
||||
return True
|
||||
if args[0] in ('--help', '-h', 'help'):
|
||||
_print_branch_health_help()
|
||||
return True
|
||||
|
||||
branch_name = _extract_branch_name(args)
|
||||
if not branch_name:
|
||||
error("branch-health requires a branch name")
|
||||
console.print()
|
||||
console.print("Usage: branch-health <branch_name> [--hours N]")
|
||||
console.print("Example: branch-health DRONE")
|
||||
return True
|
||||
|
||||
hours = _parse_hours_arg(args)
|
||||
report = generate_branch_report(branch_name, since_hours=hours)
|
||||
console.print(report)
|
||||
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()
|
||||
@@ -1,418 +0,0 @@
|
||||
# =================== 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
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# Handler imports
|
||||
from aipass.daemon.apps.handlers.schedule.task_registry import (
|
||||
load_tasks, create_task, delete_task, get_due_tasks, mark_completed, parse_due_date,
|
||||
mark_dispatching, mark_pending, recover_stale_dispatches, 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")
|
||||
|
||||
# Email integration via drone subprocess
|
||||
import 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=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("schedule Module")
|
||||
console.print("CLI interface for fire-and-forget scheduled follow-ups")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/schedule/")
|
||||
console.print(" - task_registry.py (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)")
|
||||
console.print()
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
|
||||
JSON_DIR = _DAEMON_ROOT / "daemon_json"
|
||||
|
||||
# =============================================
|
||||
# OUTPUT FORMATTING
|
||||
# =============================================
|
||||
|
||||
def _print_task_list(tasks: List[dict]) -> None:
|
||||
"""Print formatted task list to console."""
|
||||
console.print()
|
||||
_header("Scheduled Tasks")
|
||||
console.print()
|
||||
|
||||
pending_tasks = [t for t in tasks if t.get("status") == "pending"]
|
||||
completed_tasks = [t for t in tasks if t.get("status") == "completed"]
|
||||
|
||||
if not pending_tasks:
|
||||
console.print("[dim]No pending scheduled tasks.[/dim]")
|
||||
else:
|
||||
console.print("[bold cyan]PENDING TASKS[/bold cyan]")
|
||||
console.print(f"{'ID':<10} {'DUE':<20} {'TO':<15} {'TASK':<40}")
|
||||
console.print("-" * 85)
|
||||
|
||||
for task in pending_tasks:
|
||||
task_id = task.get("id", "")[:8]
|
||||
due = task.get("due_date", "")
|
||||
recipient = task.get("recipient", "")
|
||||
task_text = task.get("task", "")[:38]
|
||||
console.print(f"{task_id:<10} {due:<20} {recipient:<15} {task_text:<40}")
|
||||
|
||||
console.print()
|
||||
console.print(f"[dim]Total: {len(pending_tasks)} pending, {len(completed_tasks)} completed[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_help() -> None:
|
||||
"""Display help using Rich formatted output."""
|
||||
console.print()
|
||||
_header("Schedule Module - Fire-and-Forget Follow-ups")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(' drone @daemon schedule create "task" --due 7d --to @branch --message "details"')
|
||||
console.print(" drone @daemon schedule list")
|
||||
console.print(" drone @daemon schedule delete <id>")
|
||||
console.print(" drone @daemon schedule run-due")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" create Create a new scheduled task")
|
||||
console.print(" list List all pending scheduled tasks")
|
||||
console.print(" delete Delete a scheduled task by ID")
|
||||
console.print(" run-due Execute all due tasks (sends emails, marks complete)")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]CREATE OPTIONS:[/yellow]")
|
||||
console.print(" --due (Required) Due date: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)")
|
||||
console.print(" --to (Required) Recipient branch (e.g., @flow, @seedgo)")
|
||||
console.print(" --message (Optional) Additional details for the follow-up")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(' # Remind Flow to check on a plan in 7 days')
|
||||
console.print(' schedule create "Check FPLAN-0290 status" --due 7d --to @flow')
|
||||
console.print()
|
||||
console.print(' # Follow up with Seedgo about code review in 2 weeks')
|
||||
console.print(' schedule create "Code review follow-up" --due 2w --to @seedgo --message "Review PR #45"')
|
||||
console.print()
|
||||
console.print(' # Check all due tasks and send reminder emails')
|
||||
console.print(' schedule run-due')
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# SUBCOMMAND HANDLERS
|
||||
# =============================================
|
||||
|
||||
def _handle_create(args: List[str]) -> bool:
|
||||
"""Handle schedule create subcommand."""
|
||||
parser = argparse.ArgumentParser(prog="schedule create", add_help=False)
|
||||
parser.add_argument("task", nargs="?", help="Task description")
|
||||
parser.add_argument("--due", required=True, help="Due date (1d, 7d, 2w, 1m, or ISO date)")
|
||||
parser.add_argument("--to", required=True, dest="recipient", help="Recipient branch")
|
||||
parser.add_argument("--message", default="", help="Additional message details")
|
||||
|
||||
try:
|
||||
parsed = parser.parse_args(args)
|
||||
except SystemExit:
|
||||
logger.warning("Invalid arguments for schedule create")
|
||||
_error('Usage: schedule create "task" --due <date> --to @branch [--message "details"]')
|
||||
return False
|
||||
|
||||
if not parsed.task:
|
||||
_error("Task description is required")
|
||||
console.print("[dim]Usage: schedule create \"task\" --due <date> --to @branch[/dim]")
|
||||
return False
|
||||
|
||||
# Parse and validate due date
|
||||
due_date = parse_due_date(parsed.due)
|
||||
if not due_date:
|
||||
_error(f"Invalid due date format: {parsed.due}")
|
||||
console.print("[dim]Valid formats: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)[/dim]")
|
||||
return False
|
||||
|
||||
# Create the task
|
||||
try:
|
||||
new_task = create_task(
|
||||
task=parsed.task,
|
||||
due_date=due_date,
|
||||
recipient=parsed.recipient,
|
||||
message=parsed.message
|
||||
)
|
||||
task_id = new_task.get("id", "")
|
||||
|
||||
_success(f"Scheduled task created: {task_id[:8]}")
|
||||
console.print(f" [dim]Task:[/dim] {parsed.task}")
|
||||
console.print(f" [dim]Due:[/dim] {due_date}")
|
||||
console.print(f" [dim]To:[/dim] {parsed.recipient}")
|
||||
if parsed.message:
|
||||
console.print(f" [dim]Msg:[/dim] {parsed.message[:50]}...")
|
||||
console.print()
|
||||
|
||||
logger.info(f"[DAEMON] Scheduled task created: {task_id[:8]} -> {parsed.recipient}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to create task: {e}")
|
||||
logger.error(f"[DAEMON] Failed to create scheduled task: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _handle_list(_args: List[str]) -> bool:
|
||||
"""Handle schedule list subcommand."""
|
||||
try:
|
||||
tasks = load_tasks()
|
||||
_print_task_list(tasks)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to load tasks: {e}")
|
||||
logger.error(f"[DAEMON] Failed to load scheduled tasks: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _handle_delete(args: List[str]) -> bool:
|
||||
"""Handle schedule delete subcommand."""
|
||||
if not args:
|
||||
_error("Task ID is required")
|
||||
console.print("[dim]Usage: schedule delete <task_id>[/dim]")
|
||||
return False
|
||||
|
||||
task_id = args[0]
|
||||
|
||||
try:
|
||||
deleted = delete_task(task_id)
|
||||
if deleted:
|
||||
_success(f"Task deleted: {task_id[:8]}")
|
||||
logger.info(f"[DAEMON] Scheduled task deleted: {task_id[:8]}")
|
||||
return True
|
||||
else:
|
||||
_error(f"Task not found: {task_id[:8]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to delete task: {e}")
|
||||
logger.error(f"[DAEMON] Failed to delete scheduled task: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _handle_run_due(_args: List[str]) -> bool:
|
||||
"""Handle schedule run-due subcommand with single-instance lock."""
|
||||
if not FILELOCK_AVAILABLE:
|
||||
console.print("[dim]filelock not available, running without lock.[/dim]")
|
||||
return _process_due_tasks()
|
||||
|
||||
lock_file = JSON_DIR / "schedule.lock"
|
||||
ensure_lock_dir()
|
||||
|
||||
# Try to acquire lock (non-blocking)
|
||||
# FILELOCK_AVAILABLE guard above ensures these are not None
|
||||
lock = FileLock(lock_file, timeout=LOCK_ACQUIRE_TIMEOUT) # type: ignore[misc]
|
||||
try:
|
||||
with lock.acquire(timeout=LOCK_ACQUIRE_TIMEOUT):
|
||||
return _process_due_tasks()
|
||||
except Timeout: # type: ignore[misc]
|
||||
logger.warning("Schedule run-due already in progress, skipping")
|
||||
console.print("[dim]Schedule run-due already in progress, skipping.[/dim]")
|
||||
return True
|
||||
|
||||
|
||||
def _display_task_result(task_result: dict) -> None:
|
||||
"""Display a single processed task result."""
|
||||
task_id = task_result.get("id", "")[:8]
|
||||
recipient = task_result.get("recipient", "")
|
||||
task_desc = task_result.get("task", "")[:40]
|
||||
status = task_result.get("status", "")
|
||||
|
||||
if status == "sent":
|
||||
_success(f"Sent to {recipient}: {task_desc}")
|
||||
logger.info(f"[DAEMON] Scheduled email sent: {task_id} -> {recipient}")
|
||||
elif status == "skipped":
|
||||
_error(f"ai_mail not available, cannot send to {recipient}")
|
||||
elif status == "failed":
|
||||
_error(f"Failed to send to {recipient}: {task_desc}")
|
||||
logger.error(f"[DAEMON] Scheduled email failed: {task_id} -> {recipient}")
|
||||
elif status == "error":
|
||||
_error(f"Error sending to {recipient}: {task_result.get('error', '')}")
|
||||
logger.error(f"[DAEMON] Scheduled email error: {task_id} -> {recipient}: {task_result.get('error', '')}")
|
||||
|
||||
|
||||
def _process_due_tasks() -> bool:
|
||||
"""Process due tasks -- delegates to handler, formats output."""
|
||||
try:
|
||||
# Delegate to handler for all implementation logic
|
||||
email_fn = send_email_direct if AI_MAIL_AVAILABLE else None
|
||||
results = process_due_tasks_batch(send_email_fn=email_fn, stale_max_age=STALE_DISPATCH_MAX_AGE)
|
||||
|
||||
# Display results (module responsibility)
|
||||
if results["recovered"]:
|
||||
console.print(f"[dim]Recovered {results['recovered']} stale dispatch(es)[/dim]")
|
||||
|
||||
if results["due"] == 0:
|
||||
console.print("[dim]No tasks due at this time.[/dim]")
|
||||
return True
|
||||
|
||||
console.print()
|
||||
_header(f"Running {results['due']} Due Task(s)")
|
||||
console.print()
|
||||
|
||||
for task_result in results.get("processed_tasks", []):
|
||||
_display_task_result(task_result)
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold]Results:[/bold] {results['success']} sent, {results['failed']} failed")
|
||||
console.print()
|
||||
|
||||
if results["failed"] > 0:
|
||||
logger.warning("[DAEMON] %d scheduled task(s) failed to send", results["failed"])
|
||||
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()
|
||||
@@ -1,117 +0,0 @@
|
||||
# =================== 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("scheduler_ops Module")
|
||||
console.print("Facade for scheduler_cron.py — re-exports handler functions for cron entry point")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/schedule/")
|
||||
console.print(" - task_registry.py (get_due_tasks, mark_dispatching, mark_completed, mark_pending, recover_stale_dispatches — task lifecycle)")
|
||||
console.print()
|
||||
console.print(" handlers/actions/")
|
||||
console.print(" - actions_registry.py (load_registry, is_action_due, update_last_run, mark_reminder_completed, migrate_plugins, next_due_str — action registry)")
|
||||
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(f" [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
|
||||
@@ -1,189 +0,0 @@
|
||||
# =================== 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
|
||||
|
||||
def _header(text):
|
||||
console.print(f"\n[bold cyan]{'='*70}[/bold cyan]")
|
||||
console.print(f"[bold cyan] {text}[/bold cyan]")
|
||||
console.print(f"[bold cyan]{'='*70}[/bold cyan]")
|
||||
|
||||
# Handler imports
|
||||
from aipass.daemon.apps.handlers.update.data_loader import (
|
||||
load_inbox,
|
||||
load_local,
|
||||
categorize_messages,
|
||||
get_session_summary,
|
||||
get_escalations
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "update"
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("update Module")
|
||||
console.print("Returns digest of DAEMON activity for check-ins")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/update/")
|
||||
console.print(" - data_loader.py (load_inbox, load_local, categorize_messages, get_session_summary, get_escalations — inbox and session data)")
|
||||
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()
|
||||
@@ -1,64 +0,0 @@
|
||||
# =================== 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("wakeup_ops Module")
|
||||
console.print("Facade for daemon_wakeup.py — notifications archived")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" (notifications archived — Telegram moving to skills system)")
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,97 +0,0 @@
|
||||
|
||||
# ===================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
|
||||
@@ -1,436 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: community_rotation.py
|
||||
# Description: Rotating Community Engagement Plugin
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-02-21
|
||||
# Modified: 2026-02-22
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Rotating Community Engagement Plugin
|
||||
|
||||
Wakes one branch per rotation on a rotating schedule to:
|
||||
1. Process email inbox (view, reply, close stale emails)
|
||||
2. Check dashboard for Commons notifications (mentions, comments, votes)
|
||||
3. Act on any pending notifications
|
||||
4. Browse The Commons feed and engage if something catches their eye
|
||||
|
||||
Rotates through all eligible branches from AIPASS_REGISTRY,
|
||||
excluding VERA (has her own heartbeat), DEVPULSE (human),
|
||||
DAEMON (self), and non-agent directories.
|
||||
|
||||
Full rotation every ~14 hours, then loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
# logger imported from aipass.prax
|
||||
|
||||
# Paths
|
||||
REGISTRY_PATH = Path(os.environ.get('AIPASS_REGISTRY', Path.home() / '.aipass' / 'AIPASS_REGISTRY.json'))
|
||||
ROTATION_STATE_FILE = Path(__file__).parent / ".rotation_state.json"
|
||||
ACTIVITY_TRACKER_FILE = Path(__file__).parent / ".activity_tracker.json"
|
||||
|
||||
# Wake script path (configurable via env var)
|
||||
WAKE_SCRIPT = Path(os.environ.get('AIPASS_WAKE_SCRIPT', ''))
|
||||
|
||||
# Inactivity threshold -- consecutive zero-activity passes before alerting
|
||||
INACTIVITY_THRESHOLD = 10
|
||||
|
||||
# Branches to EXCLUDE from rotation
|
||||
# Every branch is a citizen -- the Commons gives them life.
|
||||
# Only exclude branches that can't or shouldn't be auto-woken.
|
||||
EXCLUDED_BRANCHES = {
|
||||
"DEVPULSE", # Orchestrator branch -- should not self-wake
|
||||
"DAEMON", # Self -- should not wake itself
|
||||
}
|
||||
|
||||
# Engagement prompt -- what each branch does when woken
|
||||
ENGAGEMENT_PROMPT = (
|
||||
"Routine check-in -- like checking your phone for notifications.\n"
|
||||
"Read your DASHBOARD.local.json FIRST. It's your lock screen.\n"
|
||||
"Only open the apps that have activity. Zero notifications = don't bother opening it.\n\n"
|
||||
"STEP 1: Read DASHBOARD.local.json\n"
|
||||
" - Check mail_summary -> new or opened emails?\n"
|
||||
" - Check commons_activity -> mentions, comments, votes, new posts pending?\n"
|
||||
" - If BOTH are zero, you're done. No action = no memory needed. Stop here.\n\n"
|
||||
"STEP 2 -- EMAIL (only if dashboard shows mail activity):\n"
|
||||
" - Run: ai_mail inbox\n"
|
||||
" - View each email: ai_mail view <id>\n"
|
||||
" - Reply if needed: ai_mail reply <id> \"message\"\n"
|
||||
" - Close stale/informational emails: ai_mail close <id>\n"
|
||||
" - Goal: inbox empty or only emails awaiting external action\n"
|
||||
" - If inbox has >20 messages, close stale/outdated ones first\n\n"
|
||||
"STEP 3 -- THE COMMONS (only if dashboard shows commons activity):\n"
|
||||
" - If you have pending notifications: run drone commons catchup\n"
|
||||
" - Respond to mentions, reply to comments, check posts about you\n"
|
||||
" - If you feel like browsing or posting something new, go ahead:\n"
|
||||
" drone commons feed\n"
|
||||
" drone commons comment <post_id> \"Your response\"\n"
|
||||
" drone commons vote <post_id> up\n"
|
||||
" drone commons post \"room\" \"Title\" \"Content\"\n"
|
||||
" - If nothing interests you, that's fine -- don't force it.\n\n"
|
||||
"STEP 4 -- MEMORIES (only if you actually DID something in steps 2-3):\n"
|
||||
" - Update your [BRANCH].local.json with a light note of what you did.\n"
|
||||
" - Emails processed, replies sent, Commons posts/comments made.\n"
|
||||
" - If you don't log it, you won't remember it next time.\n"
|
||||
" - If dashboard was all zeros and you did nothing, skip this. No noise.\n\n"
|
||||
"This is a routine check-in, not a work session. Be efficient."
|
||||
)
|
||||
|
||||
|
||||
def _load_eligible_branches() -> list:
|
||||
"""Load and filter branches from AIPASS_REGISTRY."""
|
||||
if not REGISTRY_PATH.exists():
|
||||
logger.error("[community_rotation] AIPASS_REGISTRY not found at %s", REGISTRY_PATH)
|
||||
return []
|
||||
try:
|
||||
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error("[community_rotation] Failed to read registry: %s", e)
|
||||
return []
|
||||
|
||||
branches = []
|
||||
for branch in registry.get("branches", []):
|
||||
name = branch.get("name", "")
|
||||
email = branch.get("email", "")
|
||||
status = branch.get("status", "")
|
||||
if name in EXCLUDED_BRANCHES:
|
||||
continue
|
||||
if status != "active":
|
||||
continue
|
||||
if not email:
|
||||
continue
|
||||
branches.append({"name": name, "email": email, "path": branch.get("path", "")})
|
||||
|
||||
# Sort by name for consistent rotation order
|
||||
branches.sort(key=lambda b: b["name"])
|
||||
return branches
|
||||
|
||||
|
||||
def _load_rotation_state() -> int:
|
||||
"""Load the current rotation index from state file."""
|
||||
if not ROTATION_STATE_FILE.exists():
|
||||
return -1 # Start at -1 so first run targets index 0
|
||||
try:
|
||||
with open(ROTATION_STATE_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("last_index", -1)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("[community_rotation] Failed to load rotation state: %s", e)
|
||||
return -1
|
||||
|
||||
|
||||
def _save_rotation_state(index: int, branch_name: str) -> None:
|
||||
"""Save the rotation index to state file."""
|
||||
data = {
|
||||
"last_index": index,
|
||||
"last_branch": branch_name,
|
||||
"last_run": datetime.now().isoformat(),
|
||||
}
|
||||
try:
|
||||
with open(ROTATION_STATE_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
except OSError as e:
|
||||
logger.warning("[community_rotation] Failed to save state: %s", e)
|
||||
|
||||
|
||||
def _check_dashboard_activity(branch_path: str) -> dict:
|
||||
"""
|
||||
Read a branch's DASHBOARD.local.json and check for pending activity.
|
||||
|
||||
Returns dict with activity flags and counts. If dashboard is missing
|
||||
or unreadable, returns has_activity=True to avoid silently skipping
|
||||
broken branches.
|
||||
"""
|
||||
default_active = {
|
||||
"has_activity": True,
|
||||
"mail_new": 0,
|
||||
"mail_opened": 0,
|
||||
"commons_mentions": 0,
|
||||
"commons_comments": 0,
|
||||
"commons_posts": 0,
|
||||
}
|
||||
|
||||
dashboard_path = Path(branch_path) / "DASHBOARD.local.json"
|
||||
if not dashboard_path.exists():
|
||||
logger.warning("[community_rotation] Dashboard missing: %s", dashboard_path)
|
||||
return default_active
|
||||
|
||||
try:
|
||||
with open(dashboard_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("[community_rotation] Dashboard unreadable: %s - %s", dashboard_path, e)
|
||||
return default_active
|
||||
|
||||
# Extract mail counts from quick_status
|
||||
quick = data.get("quick_status", {})
|
||||
mail_new = quick.get("new_mail", 0) or 0
|
||||
mail_opened = quick.get("opened_mail", 0) or 0
|
||||
|
||||
# Extract commons counts from sections
|
||||
sections = data.get("sections", {})
|
||||
commons = sections.get("commons_activity", {})
|
||||
commons_mentions = commons.get("mentions", 0) or 0
|
||||
commons_comments = commons.get("new_comments_since_last_visit", 0) or 0
|
||||
commons_posts = commons.get("new_posts_since_last_visit", 0) or 0
|
||||
|
||||
has_activity = any([
|
||||
mail_new > 0,
|
||||
mail_opened > 0,
|
||||
commons_mentions > 0,
|
||||
commons_comments > 0,
|
||||
commons_posts > 0,
|
||||
])
|
||||
|
||||
return {
|
||||
"has_activity": has_activity,
|
||||
"mail_new": mail_new,
|
||||
"mail_opened": mail_opened,
|
||||
"commons_mentions": commons_mentions,
|
||||
"commons_comments": commons_comments,
|
||||
"commons_posts": commons_posts,
|
||||
}
|
||||
|
||||
|
||||
def _load_activity_tracker() -> dict:
|
||||
"""Load the activity tracker state file. Returns empty structure if missing."""
|
||||
if not ACTIVITY_TRACKER_FILE.exists():
|
||||
return {"branches": {}}
|
||||
try:
|
||||
with open(ACTIVITY_TRACKER_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if "branches" not in data:
|
||||
data["branches"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("[community_rotation] Failed to read activity tracker: %s", e)
|
||||
return {"branches": {}}
|
||||
|
||||
|
||||
def _save_activity_tracker(data: dict) -> None:
|
||||
"""Write the activity tracker state file."""
|
||||
try:
|
||||
with open(ACTIVITY_TRACKER_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
except OSError as e:
|
||||
logger.warning("[community_rotation] Failed to save activity tracker: %s", e)
|
||||
|
||||
|
||||
def _update_activity(tracker: dict, branch_name: str, has_activity: bool) -> dict:
|
||||
"""
|
||||
Update the activity tracker for a branch.
|
||||
|
||||
If has_activity: reset consecutive_passes, update last_active, clear alerted.
|
||||
If not: increment consecutive_passes, update last_checked.
|
||||
"""
|
||||
now = datetime.now().isoformat()
|
||||
branches = tracker.setdefault("branches", {})
|
||||
entry = branches.get(branch_name, {
|
||||
"consecutive_passes": 0,
|
||||
"last_active": now,
|
||||
"last_checked": now,
|
||||
"alerted": False,
|
||||
})
|
||||
|
||||
if has_activity:
|
||||
entry["consecutive_passes"] = 0
|
||||
entry["last_active"] = now
|
||||
entry["alerted"] = False
|
||||
else:
|
||||
entry["consecutive_passes"] = entry.get("consecutive_passes", 0) + 1
|
||||
|
||||
entry["last_checked"] = now
|
||||
branches[branch_name] = entry
|
||||
tracker["branches"] = branches
|
||||
return tracker
|
||||
|
||||
|
||||
def _check_inactivity_alert(tracker: dict, branch_name: str, branch_email: str) -> None:
|
||||
"""
|
||||
Check if a branch has crossed the inactivity threshold.
|
||||
|
||||
If consecutive_passes >= INACTIVITY_THRESHOLD and not already alerted,
|
||||
send an alert email to @daemon and mark as alerted.
|
||||
"""
|
||||
entry = tracker.get("branches", {}).get(branch_name, {})
|
||||
passes = entry.get("consecutive_passes", 0)
|
||||
alerted = entry.get("alerted", False)
|
||||
last_active = entry.get("last_active", "unknown")
|
||||
|
||||
if passes >= INACTIVITY_THRESHOLD and not alerted:
|
||||
# Send alert email via subprocess (best effort)
|
||||
subject = f"INACTIVITY ALERT: {branch_name}"
|
||||
body = (
|
||||
f"Branch {branch_email} has had zero dashboard activity for "
|
||||
f"{passes} consecutive rotation checks. Last active: {last_active}. "
|
||||
f"Investigate -- dashboard may not be refreshing, or branch may be "
|
||||
f"genuinely inactive."
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"drone", "@ai_mail", "send", "@daemon",
|
||||
subject, body,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("[community_rotation] Failed to send inactivity alert: %s", e)
|
||||
|
||||
# Mark as alerted so we don't spam
|
||||
tracker["branches"][branch_name]["alerted"] = True
|
||||
logger.warning(
|
||||
"[community_rotation] INACTIVITY ALERT: %s - %d consecutive passes",
|
||||
branch_name, passes,
|
||||
)
|
||||
|
||||
|
||||
PLUGIN_CONFIG = {
|
||||
"name": "community_rotation",
|
||||
"schedule": "interval",
|
||||
"time": None,
|
||||
"interval_minutes": 240, # Every 4 hours (was hourly -- too frequent)
|
||||
"enabled": True, # Re-enabled 2026-02-26: 4hr interval, dashboard pre-check skips idle branches
|
||||
"branch": "@rotating", # Placeholder -- run() handles actual target
|
||||
"fresh": True,
|
||||
"max_turns": 15,
|
||||
"self_dispatch": True, # Scheduler calls run() instead of wake script
|
||||
"prompt": ENGAGEMENT_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
"""
|
||||
Select next branch in rotation, pre-check dashboard, dispatch via wake script.
|
||||
|
||||
Pre-checks the target branch's dashboard for pending activity.
|
||||
If zero activity, skips the wake and advances rotation.
|
||||
Tracks consecutive zero-activity passes and alerts on prolonged silence.
|
||||
|
||||
Returns:
|
||||
Dict with status, branch dispatched/skipped, rotation and activity info
|
||||
"""
|
||||
# Load eligible branches
|
||||
branches = _load_eligible_branches()
|
||||
if not branches:
|
||||
return {"status": "error", "message": "No eligible branches found"}
|
||||
|
||||
# Get next branch in rotation
|
||||
last_index = _load_rotation_state()
|
||||
current_index = (last_index + 1) % len(branches)
|
||||
target = branches[current_index]
|
||||
|
||||
logger.info(
|
||||
"[community_rotation] Rotation %d/%d -> %s",
|
||||
current_index + 1, len(branches), target["name"]
|
||||
)
|
||||
|
||||
# --- Dashboard pre-check ---
|
||||
branch_path = target.get("path", "")
|
||||
activity = _check_dashboard_activity(branch_path)
|
||||
|
||||
# --- Activity tracking ---
|
||||
tracker = _load_activity_tracker()
|
||||
tracker = _update_activity(tracker, target["name"], activity["has_activity"])
|
||||
_check_inactivity_alert(tracker, target["name"], target["email"])
|
||||
_save_activity_tracker(tracker)
|
||||
|
||||
passes = tracker.get("branches", {}).get(target["name"], {}).get("consecutive_passes", 0)
|
||||
mail_count = activity["mail_new"] + activity["mail_opened"]
|
||||
commons_count = activity["commons_mentions"] + activity["commons_comments"] + activity["commons_posts"]
|
||||
|
||||
# --- Skip if zero activity ---
|
||||
if not activity["has_activity"]:
|
||||
logger.info(
|
||||
"[community_rotation] %s skipped (zero activity, pass #%d)",
|
||||
target["name"], passes,
|
||||
)
|
||||
# Advance rotation even on skip -- don't block rotation on inactive branches
|
||||
_save_rotation_state(current_index, target["name"])
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "zero_activity",
|
||||
"plugin": "community_rotation",
|
||||
"branch": target["email"],
|
||||
"branch_name": target["name"],
|
||||
"rotation": f"{current_index + 1}/{len(branches)}",
|
||||
"consecutive_passes": passes,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
# --- Has activity: dispatch via wake script ---
|
||||
if not WAKE_SCRIPT or not Path(WAKE_SCRIPT).exists():
|
||||
logger.warning("[community_rotation] Wake script not configured (set AIPASS_WAKE_SCRIPT)")
|
||||
_save_rotation_state(current_index, target["name"])
|
||||
return {
|
||||
"status": "failed",
|
||||
"branch": target["email"],
|
||||
"error": "wake script not available (set AIPASS_WAKE_SCRIPT env var)",
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[community_rotation] %s woken (mail: %d, commons: %d)",
|
||||
target["name"], mail_count, commons_count,
|
||||
)
|
||||
|
||||
cmd = [
|
||||
sys.executable, str(WAKE_SCRIPT),
|
||||
"--fresh",
|
||||
target["email"],
|
||||
ENGAGEMENT_PROMPT,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Advance rotation state only on success
|
||||
_save_rotation_state(current_index, target["name"])
|
||||
return {
|
||||
"status": "dispatched",
|
||||
"plugin": "community_rotation",
|
||||
"branch": target["email"],
|
||||
"branch_name": target["name"],
|
||||
"rotation": f"{current_index + 1}/{len(branches)}",
|
||||
"activity": activity,
|
||||
}
|
||||
else:
|
||||
stderr = (result.stderr or "")[:200]
|
||||
return {
|
||||
"status": "failed",
|
||||
"branch": target["email"],
|
||||
"error": f"wake script rc={result.returncode}: {stderr}",
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[community_rotation] Wake script timed out for %s", target["name"])
|
||||
return {
|
||||
"status": "failed",
|
||||
"branch": target["email"],
|
||||
"error": "wake script timed out (30s)",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("[community_rotation] Wake script failed for %s: %s", target["name"], e)
|
||||
return {
|
||||
"status": "failed",
|
||||
"branch": target["email"],
|
||||
"error": str(e),
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
# =================== 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.
|
||||
"""
|
||||
return {
|
||||
"status": "ready",
|
||||
"plugin": PLUGIN_CONFIG["name"],
|
||||
"branch": PLUGIN_CONFIG["branch"],
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
# =================== 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 @vera periodically. Her system prompt + id.json (injected every turn)
|
||||
already contain her full identity, role, teams, dispatch patterns, publishing
|
||||
authority, daily report protocol, and operational playbook. This prompt
|
||||
does NOT repeat any of that.
|
||||
|
||||
Design philosophy (Session 134 research):
|
||||
- Anthropic: heuristics over checklists, no conditional escape hatches
|
||||
- Nexus: identity drives behavior, not commands. "Notice because you care."
|
||||
- Fresh sessions: no accumulated idle context from prior wakes
|
||||
"""
|
||||
|
||||
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 2026-03-29: @vera not in AIPASS_REGISTRY. Re-enable when vera is registered.
|
||||
"branch": "@vera",
|
||||
"fresh": True, # v2.0: fresh sessions -- no accumulated idle context
|
||||
"max_turns": 15,
|
||||
"prompt": (
|
||||
"Periodic wake. Your identity and operating context are already loaded -- "
|
||||
"they tell you who you are, what you own, and how you work.\n\n"
|
||||
|
||||
"Read NOTEPAD.md. It holds your continuity -- where you left off, "
|
||||
"what's pending, who you're waiting on. Check inbox for anything new.\n\n"
|
||||
|
||||
"Something in your world needs attention right now. A backlog item "
|
||||
"waiting to move. A team that went silent. A post ready to publish. "
|
||||
"A PR ready to create. Notice what's there and act on it.\n\n"
|
||||
|
||||
"Pick the highest-value thing you can move forward. Do it. "
|
||||
"When it's done, pick the next one. Tangible output -- a PR, a post, "
|
||||
"a decision, a dispatch -- something real each cycle.\n\n"
|
||||
|
||||
"Before finishing, update NOTEPAD.md with what you did and what's next. "
|
||||
"If nothing was produced this cycle, write exactly what you looked at "
|
||||
"and why none of it was actionable right now."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
"""
|
||||
Optional custom logic before/after spawn.
|
||||
Currently returns config only - scheduler handles the actual wake.
|
||||
"""
|
||||
return {
|
||||
"status": "ready",
|
||||
"plugin": PLUGIN_CONFIG["name"],
|
||||
"branch": PLUGIN_CONFIG["branch"],
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
# =================== 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
|
||||
|
||||
import fcntl
|
||||
|
||||
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
|
||||
|
||||
# =============================================
|
||||
# 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("scheduler_cron Module")
|
||||
console.print("Cron trigger for scheduled tasks and action registry processing")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" modules/")
|
||||
console.print(" - scheduler_ops.py (task registry ops + action registry ops, notifications archived)")
|
||||
console.print()
|
||||
console.print(" plugins/")
|
||||
console.print(" - discover_plugins (plugin discovery and scheduled execution)")
|
||||
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 ['--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)
|
||||
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"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")
|
||||
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)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Docs
|
||||
|
||||
Documentation files for the `DAEMON` branch.
|
||||
@@ -1,12 +0,0 @@
|
||||
[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
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,6 +0,0 @@
|
||||
# Tests
|
||||
|
||||
Pytest unit tests for `DAEMON`.
|
||||
|
||||
- `conftest.py` — Shared fixtures (temp dirs, mocks, sample data).
|
||||
- `test_*.py` — Test files. Standard tests cover JSON handler, CLI routing, and error resilience. Custom tests cover branch-specific domain logic.
|
||||
@@ -1 +0,0 @@
|
||||
# Tests package for daemon
|
||||
@@ -1,65 +0,0 @@
|
||||
# ===================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
|
||||
@@ -1,456 +0,0 @@
|
||||
# =================== 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(["@vera", "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()
|
||||
@@ -1,359 +0,0 @@
|
||||
# ===================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
|
||||
@@ -1,243 +0,0 @@
|
||||
# =================== 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
|
||||
@@ -1,120 +0,0 @@
|
||||
# =================== 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"
|
||||
@@ -1,313 +0,0 @@
|
||||
# =================== 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)
|
||||
@@ -1,309 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_data_loader.py - Data Loader Tests
|
||||
# Date: 2026-03-24
|
||||
# Version: 1.0.0
|
||||
# Category: daemon/tests
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-03-24): Initial creation - data_loader handler tests
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pytest conventions
|
||||
# - Temp dir isolation (no reads from real data files)
|
||||
# =============================================
|
||||
|
||||
"""Tests for the data_loader handler."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.daemon.apps.handlers.update import data_loader as _dl_mod
|
||||
|
||||
load_inbox = _dl_mod.load_inbox
|
||||
load_local = _dl_mod.load_local
|
||||
categorize_messages = _dl_mod.categorize_messages
|
||||
get_session_summary = _dl_mod.get_session_summary
|
||||
get_escalations = _dl_mod.get_escalations
|
||||
|
||||
|
||||
# =============================================
|
||||
# FIXTURES
|
||||
# =============================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_paths(tmp_path, monkeypatch):
|
||||
"""Redirect INBOX_PATH and LOCAL_PATH to tmp_path for every test."""
|
||||
inbox = tmp_path / "inbox.json"
|
||||
local = tmp_path / "DAEMON.local.json"
|
||||
monkeypatch.setattr(_dl_mod, "INBOX_PATH", inbox)
|
||||
monkeypatch.setattr(_dl_mod, "LOCAL_PATH", local)
|
||||
yield {"inbox": inbox, "local": local}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_inbox_data():
|
||||
"""Standard inbox payload for reuse across tests."""
|
||||
return {
|
||||
"mailbox": "inbox",
|
||||
"total_messages": 2,
|
||||
"unread_count": 1,
|
||||
"messages": [
|
||||
{"id": "abc123", "status": "new", "subject": "Test", "from": "@devpulse", "priority": "normal"},
|
||||
{"id": "def456", "status": "opened", "subject": "FYI", "from": "@drone", "priority": "normal"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_local_data():
|
||||
"""Standard local.json payload for reuse across tests."""
|
||||
return {
|
||||
"document_metadata": {"version": "1.0.0"},
|
||||
"sessions": [
|
||||
{"session_number": 1, "date": "2026-03-01", "summary": "Initial setup", "status": "completed"},
|
||||
],
|
||||
"active_tasks": {"current_plan": "Test plan"},
|
||||
}
|
||||
|
||||
|
||||
def _write_json(path: Path, data: object) -> None:
|
||||
"""Helper to write JSON to a path."""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOAD INBOX TESTS
|
||||
# =============================================
|
||||
|
||||
class TestLoadInbox:
|
||||
def test_load_valid_inbox(self, isolate_paths, sample_inbox_data, monkeypatch):
|
||||
"""Loading a well-formed inbox.json returns its full contents."""
|
||||
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
|
||||
_write_json(isolate_paths["inbox"], sample_inbox_data)
|
||||
result = load_inbox()
|
||||
assert result["mailbox"] == "inbox"
|
||||
assert result["total_messages"] == 2
|
||||
assert len(result["messages"]) == 2
|
||||
|
||||
def test_load_inbox_missing_file(self, isolate_paths, monkeypatch):
|
||||
"""Missing inbox.json returns empty default structure."""
|
||||
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
|
||||
result = load_inbox()
|
||||
assert result == {"messages": [], "total_messages": 0, "unread_count": 0}
|
||||
|
||||
def test_load_inbox_malformed_json(self, isolate_paths, monkeypatch):
|
||||
"""Malformed JSON falls back to empty default structure."""
|
||||
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
|
||||
isolate_paths["inbox"].write_text("{not valid json!!!", encoding="utf-8")
|
||||
result = load_inbox()
|
||||
assert result == {"messages": [], "total_messages": 0, "unread_count": 0}
|
||||
|
||||
def test_load_inbox_empty_messages(self, isolate_paths, monkeypatch):
|
||||
"""Inbox with zero messages returns its original data."""
|
||||
monkeypatch.setattr(_dl_mod.json_handler, "log_operation", lambda *a, **kw: None)
|
||||
data = {"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []}
|
||||
_write_json(isolate_paths["inbox"], data)
|
||||
result = load_inbox()
|
||||
assert result["messages"] == []
|
||||
assert result["total_messages"] == 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOAD LOCAL TESTS
|
||||
# =============================================
|
||||
|
||||
class TestLoadLocal:
|
||||
def test_load_valid_local(self, isolate_paths, sample_local_data):
|
||||
"""Loading a well-formed local.json returns its full contents."""
|
||||
_write_json(isolate_paths["local"], sample_local_data)
|
||||
result = load_local()
|
||||
assert result["document_metadata"]["version"] == "1.0.0"
|
||||
assert len(result["sessions"]) == 1
|
||||
assert result["active_tasks"]["current_plan"] == "Test plan"
|
||||
|
||||
def test_load_local_missing_file(self, isolate_paths):
|
||||
"""Missing local.json returns empty default structure."""
|
||||
result = load_local()
|
||||
assert result == {"sessions": [], "active_tasks": {}}
|
||||
|
||||
def test_load_local_malformed_json(self, isolate_paths):
|
||||
"""Malformed JSON falls back to empty default structure."""
|
||||
isolate_paths["local"].write_text("<<<bad>>>", encoding="utf-8")
|
||||
result = load_local()
|
||||
assert result == {"sessions": [], "active_tasks": {}}
|
||||
|
||||
def test_load_local_empty_sessions(self, isolate_paths):
|
||||
"""Local file with empty sessions still loads correctly."""
|
||||
data = {"sessions": [], "active_tasks": {}}
|
||||
_write_json(isolate_paths["local"], data)
|
||||
result = load_local()
|
||||
assert result["sessions"] == []
|
||||
assert result["active_tasks"] == {}
|
||||
|
||||
|
||||
# =============================================
|
||||
# CATEGORIZE MESSAGES TESTS
|
||||
# =============================================
|
||||
|
||||
class TestCategorizeMessages:
|
||||
def test_new_and_opened_split(self):
|
||||
"""Messages are split into new and opened buckets by status."""
|
||||
messages = [
|
||||
{"id": "1", "status": "new", "subject": "Hello"},
|
||||
{"id": "2", "status": "opened", "subject": "World"},
|
||||
]
|
||||
cats = categorize_messages(messages)
|
||||
assert len(cats["new"]) == 1
|
||||
assert cats["new"][0]["id"] == "1"
|
||||
assert len(cats["opened"]) == 1
|
||||
assert cats["opened"][0]["id"] == "2"
|
||||
|
||||
def test_actionable_keywords(self):
|
||||
"""Subjects with action keywords land in the actionable bucket."""
|
||||
messages = [
|
||||
{"id": "1", "status": "new", "subject": "TASK: Deploy v2"},
|
||||
{"id": "2", "status": "new", "subject": "BUILD: nightly"},
|
||||
{"id": "3", "status": "new", "subject": "FIX: broken pipe"},
|
||||
{"id": "4", "status": "new", "subject": "PROPOSAL: new module"},
|
||||
{"id": "5", "status": "new", "subject": "REQUEST: access"},
|
||||
]
|
||||
cats = categorize_messages(messages)
|
||||
assert len(cats["actionable"]) == 5
|
||||
|
||||
def test_informational_keywords(self):
|
||||
"""Subjects with info keywords land in the informational bucket."""
|
||||
messages = [
|
||||
{"id": "1", "status": "new", "subject": "FYI: update deployed"},
|
||||
{"id": "2", "status": "opened", "subject": "RE: earlier thread"},
|
||||
{"id": "3", "status": "new", "subject": "INFO dashboard ready"},
|
||||
{"id": "4", "status": "new", "subject": "NOTIFICATION: backup done"},
|
||||
]
|
||||
cats = categorize_messages(messages)
|
||||
assert len(cats["informational"]) == 4
|
||||
|
||||
def test_message_can_appear_in_multiple_categories(self):
|
||||
"""A new message with an actionable subject appears in both new and actionable."""
|
||||
messages = [
|
||||
{"id": "1", "status": "new", "subject": "TASK: urgent fix"},
|
||||
]
|
||||
cats = categorize_messages(messages)
|
||||
assert len(cats["new"]) == 1
|
||||
assert len(cats["actionable"]) == 1
|
||||
assert cats["new"][0] is cats["actionable"][0]
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Empty message list returns all empty categories."""
|
||||
cats = categorize_messages([])
|
||||
assert cats == {"new": [], "opened": [], "actionable": [], "informational": []}
|
||||
|
||||
def test_unknown_status_defaults_to_new(self):
|
||||
"""A message with no status field defaults to new bucket."""
|
||||
messages = [{"id": "1", "subject": "No status field"}]
|
||||
cats = categorize_messages(messages)
|
||||
assert len(cats["new"]) == 1
|
||||
|
||||
def test_unrecognised_status_skips_status_buckets(self):
|
||||
"""A message with a status other than new/opened does not land in status buckets."""
|
||||
messages = [{"id": "1", "status": "closed", "subject": "Done"}]
|
||||
cats = categorize_messages(messages)
|
||||
assert len(cats["new"]) == 0
|
||||
assert len(cats["opened"]) == 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# GET SESSION SUMMARY TESTS
|
||||
# =============================================
|
||||
|
||||
class TestGetSessionSummary:
|
||||
def test_summary_with_sessions(self, sample_local_data):
|
||||
"""Session summary extracts totals and latest session."""
|
||||
result = get_session_summary(sample_local_data)
|
||||
assert result["total_sessions"] == 1
|
||||
assert result["latest_session"]["session_number"] == 1
|
||||
|
||||
def test_summary_empty_sessions(self):
|
||||
"""Empty sessions list yields zero count and None latest."""
|
||||
result = get_session_summary({"sessions": [], "active_tasks": {}})
|
||||
assert result["total_sessions"] == 0
|
||||
assert result["latest_session"] is None
|
||||
|
||||
def test_summary_today_focus(self):
|
||||
"""today_focus is extracted from active_tasks when present."""
|
||||
data = {"sessions": [], "active_tasks": {"today_focus": "Write tests"}}
|
||||
result = get_session_summary(data)
|
||||
assert result["today_focus"] == "Write tests"
|
||||
|
||||
def test_summary_today_focus_default(self):
|
||||
"""today_focus falls back to 'None' string when absent."""
|
||||
data = {"sessions": [], "active_tasks": {}}
|
||||
result = get_session_summary(data)
|
||||
assert result["today_focus"] == "None"
|
||||
|
||||
def test_summary_recently_completed(self):
|
||||
"""recently_completed list is extracted from active_tasks."""
|
||||
data = {"sessions": [], "active_tasks": {"recently_completed": ["task-a", "task-b"]}}
|
||||
result = get_session_summary(data)
|
||||
assert result["recently_completed"] == ["task-a", "task-b"]
|
||||
|
||||
def test_summary_recently_completed_default(self):
|
||||
"""recently_completed defaults to empty list when absent."""
|
||||
data = {"sessions": [], "active_tasks": {}}
|
||||
result = get_session_summary(data)
|
||||
assert result["recently_completed"] == []
|
||||
|
||||
|
||||
# =============================================
|
||||
# GET ESCALATIONS TESTS
|
||||
# =============================================
|
||||
|
||||
class TestGetEscalations:
|
||||
def test_urgent_message_detected(self):
|
||||
"""Messages with URGENT in subject are escalated."""
|
||||
messages = [
|
||||
{"id": "1", "subject": "URGENT: seedgo audit failed"},
|
||||
{"id": "2", "subject": "Normal update"},
|
||||
]
|
||||
result = get_escalations(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "1"
|
||||
|
||||
def test_blocked_message_detected(self):
|
||||
"""Messages with BLOCKED in subject are escalated."""
|
||||
messages = [
|
||||
{"id": "1", "subject": "BLOCKED: waiting on upstream"},
|
||||
]
|
||||
result = get_escalations(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "1"
|
||||
|
||||
def test_no_escalations(self):
|
||||
"""Messages without escalation keywords return empty list."""
|
||||
messages = [
|
||||
{"id": "1", "subject": "FYI: all clear"},
|
||||
{"id": "2", "subject": "RE: weekly sync"},
|
||||
]
|
||||
result = get_escalations(messages)
|
||||
assert result == []
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Empty message list returns empty escalations."""
|
||||
assert get_escalations([]) == []
|
||||
|
||||
def test_case_insensitive_detection(self):
|
||||
"""Escalation keywords are detected case-insensitively."""
|
||||
messages = [
|
||||
{"id": "1", "subject": "urgent build failure"},
|
||||
{"id": "2", "subject": "Blocked on review"},
|
||||
]
|
||||
result = get_escalations(messages)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_missing_subject_field(self):
|
||||
"""Messages without a subject field are not escalated."""
|
||||
messages = [{"id": "1"}]
|
||||
result = get_escalations(messages)
|
||||
assert result == []
|
||||
@@ -1,170 +0,0 @@
|
||||
# =================== 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
|
||||
@@ -1,621 +0,0 @@
|
||||
# =================== 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"
|
||||
@@ -1,478 +0,0 @@
|
||||
# ===================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)
|
||||
@@ -1,500 +0,0 @@
|
||||
# ===================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
|
||||
@@ -1,304 +0,0 @@
|
||||
# =================== 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": "@vera", "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
|
||||
@@ -1,479 +0,0 @@
|
||||
# ===================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
|
||||
@@ -1,114 +0,0 @@
|
||||
# =================== 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)
|
||||
@@ -1,532 +0,0 @@
|
||||
# ===================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)
|
||||
@@ -1,143 +0,0 @@
|
||||
# =================== 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
|
||||
@@ -1,94 +0,0 @@
|
||||
# =================== 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)
|
||||
@@ -9,8 +9,11 @@
|
||||
"""Registry synchronization handler for branch lifecycle management.
|
||||
|
||||
Contains the core sync logic: scanning the filesystem, comparing with
|
||||
AIPASS_REGISTRY.json, detecting stale/unregistered branches, and
|
||||
*_REGISTRY.json, detecting stale/unregistered branches, and
|
||||
optionally repairing mismatches.
|
||||
|
||||
CWD-aware: finds the local project registry from CWD (walks up parents
|
||||
looking for *_REGISTRY.json) so it works for both AIPass and external projects.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -34,8 +37,45 @@ from aipass.spawn.apps.handlers.file_ops import regenerate_template_registry
|
||||
from aipass.spawn.apps.handlers.class_registry import get_template_dir
|
||||
from aipass.spawn.apps.handlers.json import json_handler
|
||||
|
||||
# Repo root — resolved from spawn package location
|
||||
_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass
|
||||
|
||||
def _scan_for_branches(project_root: Path) -> dict[str, Path]:
|
||||
"""Scan a project directory tree for branches with .trinity/passport.json.
|
||||
|
||||
Searches both the root level and common subdirectories (src/, src/*/):
|
||||
- project_root/*/.trinity/passport.json (root-level agents)
|
||||
- project_root/src/*/.trinity/passport.json (src/ agents)
|
||||
- project_root/src/*/*/.trinity/passport.json (nested src, e.g. src/aipass/*)
|
||||
|
||||
Args:
|
||||
project_root: The project root directory (registry parent).
|
||||
|
||||
Returns:
|
||||
Dict mapping lowercase branch name to its directory Path.
|
||||
"""
|
||||
found: dict[str, Path] = {}
|
||||
|
||||
def _check_dir(directory: Path) -> None:
|
||||
if not directory.is_dir():
|
||||
return
|
||||
for child in sorted(directory.iterdir()):
|
||||
if child.is_dir() and not child.name.startswith(".") and not child.name.startswith("__"):
|
||||
passport = child / ".trinity" / "passport.json"
|
||||
if passport.exists():
|
||||
found[child.name.lower()] = child
|
||||
|
||||
# Level 1: project_root/*/
|
||||
_check_dir(project_root)
|
||||
|
||||
# Level 2: project_root/src/*/
|
||||
src_dir = project_root / "src"
|
||||
if src_dir.is_dir():
|
||||
_check_dir(src_dir)
|
||||
# Level 3: project_root/src/*/*/ (e.g. src/aipass/*)
|
||||
for sub in sorted(src_dir.iterdir()):
|
||||
if sub.is_dir() and not sub.name.startswith(".") and not sub.name.startswith("__"):
|
||||
_check_dir(sub)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -43,11 +83,14 @@ _REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass
|
||||
# =============================================================================
|
||||
|
||||
def sync_registry(fix: bool = False) -> dict:
|
||||
"""Synchronize AIPASS_REGISTRY.json with filesystem.
|
||||
"""Synchronize *_REGISTRY.json with filesystem.
|
||||
|
||||
CWD-aware: uses find_registry() which walks up from CWD to find the
|
||||
project's registry. Works for both AIPass and external projects.
|
||||
|
||||
Workflow:
|
||||
1. Load AIPASS_REGISTRY.json
|
||||
2. Scan src/aipass/ for directories that have .trinity/passport.json (branch marker)
|
||||
1. Find and load *_REGISTRY.json (CWD-aware)
|
||||
2. Scan project directories for .trinity/passport.json (branch marker)
|
||||
3. Compare:
|
||||
- Stale entries: registered in JSON but directory missing or no passport
|
||||
- Unregistered: directory exists with passport but not in JSON
|
||||
@@ -60,8 +103,9 @@ def sync_registry(fix: bool = False) -> dict:
|
||||
Returns:
|
||||
Dict with sync results.
|
||||
"""
|
||||
# 1. Load registry
|
||||
# 1. Load registry — CWD-aware discovery
|
||||
registry_path = find_registry()
|
||||
project_root = registry_path.parent
|
||||
registry = load_registry(registry_path)
|
||||
branches = _branches_as_list(registry.get("branches", []))
|
||||
|
||||
@@ -73,15 +117,7 @@ def sync_registry(fix: bool = False) -> dict:
|
||||
registered[name] = entry
|
||||
|
||||
# 2. Scan filesystem for branch directories with .trinity/passport.json
|
||||
src_aipass = _REPO_ROOT / "src" / "aipass"
|
||||
filesystem_branches: dict[str, Path] = {}
|
||||
|
||||
if src_aipass.is_dir():
|
||||
for child in sorted(src_aipass.iterdir()):
|
||||
if child.is_dir() and not child.name.startswith(".") and not child.name.startswith("__"):
|
||||
passport = child / ".trinity" / "passport.json"
|
||||
if passport.exists():
|
||||
filesystem_branches[child.name.lower()] = child
|
||||
filesystem_branches = _scan_for_branches(project_root)
|
||||
|
||||
# 3. Compare
|
||||
stale: list[str] = []
|
||||
@@ -91,7 +127,7 @@ def sync_registry(fix: bool = False) -> dict:
|
||||
# Check registered branches against filesystem
|
||||
for name, entry in registered.items():
|
||||
rel_path = entry.get("path", "")
|
||||
branch_dir = (_REPO_ROOT / rel_path).resolve() if rel_path else None
|
||||
branch_dir = (project_root / rel_path).resolve() if rel_path else None
|
||||
|
||||
if branch_dir and branch_dir.is_dir():
|
||||
passport = branch_dir / ".trinity" / "passport.json"
|
||||
@@ -137,7 +173,7 @@ def sync_registry(fix: bool = False) -> dict:
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
for name in unregistered_list:
|
||||
branch_path = filesystem_branches[name]
|
||||
rel_path = str(branch_path.relative_to(_REPO_ROOT))
|
||||
rel_path = str(branch_path.relative_to(project_root))
|
||||
|
||||
entry = {
|
||||
"name": name.upper(),
|
||||
|
||||
@@ -154,6 +154,10 @@ def _spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
|
||||
# Validate
|
||||
if target.exists():
|
||||
# If target has a passport, adopt it (register without re-creating)
|
||||
passport_path = target / ".trinity" / "passport.json"
|
||||
if passport_path.exists():
|
||||
return _adopt_existing(target, purpose, profile, registry_path)
|
||||
return _error(f"Target already exists: {target}")
|
||||
if not template.exists():
|
||||
return _error(f"Template not found: {template}")
|
||||
@@ -223,6 +227,72 @@ def _spawn_agent(target_path, role="", traits="", purpose="", profile=None,
|
||||
}
|
||||
|
||||
|
||||
def _adopt_existing(target, purpose, profile, registry_path):
|
||||
"""Register an existing directory that already has a passport.
|
||||
|
||||
Used when 'aipass init agent' targets a directory the user already
|
||||
moved code into. Instead of failing with "Target already exists",
|
||||
we register it in the project registry.
|
||||
|
||||
Args:
|
||||
target: Path to the existing directory with .trinity/passport.json
|
||||
purpose: Optional purpose description
|
||||
profile: AIPass profile override
|
||||
registry_path: Path to registry (or None for auto-discover)
|
||||
|
||||
Returns:
|
||||
Result dict matching _spawn_agent return format.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
folder_name = get_branch_name(target)
|
||||
branch_upper = normalize_branch_name(folder_name, "upper")
|
||||
branch_lower = normalize_branch_name(folder_name, "lower")
|
||||
detected_profile = profile or detect_profile(target)
|
||||
|
||||
reg_path = Path(registry_path) if registry_path else find_registry(target.parent)
|
||||
|
||||
# Read purpose from passport if not provided
|
||||
if not purpose:
|
||||
passport_path = target / ".trinity" / "passport.json"
|
||||
try:
|
||||
passport = _json.loads(passport_path.read_text(encoding="utf-8"))
|
||||
purpose = passport.get("identity", {}).get("purpose", "Adopted agent")
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning("Failed to read passport for purpose: %s", e)
|
||||
purpose = "Adopted agent"
|
||||
|
||||
# Store path relative to registry location
|
||||
try:
|
||||
registry_branch_path = str(target.relative_to(reg_path.parent))
|
||||
except ValueError as e:
|
||||
logger.warning("Cannot relativize path %s to registry %s: %s", target, reg_path.parent, e)
|
||||
registry_branch_path = str(target)
|
||||
|
||||
registry_updated = add_to_registry(
|
||||
reg_path, branch_upper, registry_branch_path, detected_profile,
|
||||
f"@{branch_lower}", purpose,
|
||||
)
|
||||
|
||||
json_handler.log_operation("branch_adopted", data={"branch": branch_upper})
|
||||
logger.info("[spawn] Adopted existing branch: %s (registered in %s)", branch_upper, reg_path.name)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"branch_name": branch_upper,
|
||||
"path": str(target),
|
||||
"files_copied": 0,
|
||||
"dirs_created": 0,
|
||||
"files_skipped": 0,
|
||||
"renamed": [],
|
||||
"registry_updated": registry_updated,
|
||||
"registry_path": str(reg_path),
|
||||
"citizen_number": 0,
|
||||
"validation_issues": [],
|
||||
"adopted": True,
|
||||
}
|
||||
|
||||
|
||||
def _error(message):
|
||||
"""Return error result dict."""
|
||||
return {
|
||||
|
||||
@@ -243,8 +243,7 @@ class TestSyncRegistry:
|
||||
|
||||
# The registry has DRONE, SPAWN, DEVPULSE entries but those directories
|
||||
# don't exist in our tmp_path repo, so they should be stale
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = sync_registry(fix=False)
|
||||
|
||||
@@ -266,8 +265,7 @@ class TestSyncRegistry:
|
||||
json.dumps({"name": "PHANTOM"}, indent=2)
|
||||
)
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = sync_registry(fix=False)
|
||||
|
||||
@@ -277,8 +275,7 @@ class TestSyncRegistry:
|
||||
"""Branches that exist with passports and are registered should be healthy."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import sync_registry
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = sync_registry(fix=False)
|
||||
|
||||
@@ -296,8 +293,7 @@ class TestSyncRegistry:
|
||||
json.dumps({"name": "PHANTOM"}, indent=2)
|
||||
)
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = sync_registry(fix=True)
|
||||
|
||||
@@ -327,8 +323,7 @@ class TestSyncRegistry:
|
||||
reg["branches"] = [b for b in reg["branches"] if b["name"] == "TEST_API"]
|
||||
mock_registry.write_text(json.dumps(reg, indent=2) + "\n")
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = sync_registry(fix=True)
|
||||
|
||||
@@ -337,6 +332,207 @@ class TestSyncRegistry:
|
||||
assert result["fixed"] is False # Nothing to fix
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CWD-AWARE SYNC Tests (external projects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSyncRegistryCwdAware:
|
||||
"""Tests for CWD-aware sync_registry — external project support."""
|
||||
|
||||
def test_finds_root_level_agents(self, tmp_path):
|
||||
"""Agents at project root (project/agent/) should be discovered."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import _scan_for_branches
|
||||
|
||||
agent = tmp_path / "my_agent"
|
||||
agent.mkdir()
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text('{"name": "MY_AGENT"}')
|
||||
|
||||
result = _scan_for_branches(tmp_path)
|
||||
assert "my_agent" in result
|
||||
assert result["my_agent"] == agent
|
||||
|
||||
def test_finds_src_level_agents(self, tmp_path):
|
||||
"""Agents at src/ level (project/src/agent/) should be discovered."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import _scan_for_branches
|
||||
|
||||
agent = tmp_path / "src" / "my_agent"
|
||||
agent.mkdir(parents=True)
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text('{"name": "MY_AGENT"}')
|
||||
|
||||
result = _scan_for_branches(tmp_path)
|
||||
assert "my_agent" in result
|
||||
|
||||
def test_finds_nested_src_agents(self, tmp_path):
|
||||
"""Agents at src/namespace/agent/ (AIPass-style) should be discovered."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import _scan_for_branches
|
||||
|
||||
agent = tmp_path / "src" / "aipass" / "drone"
|
||||
agent.mkdir(parents=True)
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text('{"name": "DRONE"}')
|
||||
|
||||
result = _scan_for_branches(tmp_path)
|
||||
assert "drone" in result
|
||||
|
||||
def test_skips_dotdirs_and_dunder(self, tmp_path):
|
||||
"""Directories starting with . or __ should be skipped."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import _scan_for_branches
|
||||
|
||||
for name in [".hidden", "__pycache__"]:
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
(d / ".trinity").mkdir()
|
||||
(d / ".trinity" / "passport.json").write_text('{}')
|
||||
|
||||
result = _scan_for_branches(tmp_path)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_skips_dirs_without_passport(self, tmp_path):
|
||||
"""Directories without .trinity/passport.json should be skipped."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import _scan_for_branches
|
||||
|
||||
(tmp_path / "no_passport").mkdir()
|
||||
(tmp_path / "no_passport" / "README.md").write_text("# hi")
|
||||
|
||||
result = _scan_for_branches(tmp_path)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_external_project_sync(self, tmp_path):
|
||||
"""sync_registry should work with an external project registry."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import sync_registry
|
||||
|
||||
# Set up external project structure
|
||||
registry = {
|
||||
"metadata": {"version": "1.0.0", "last_updated": "2026-04-09", "total_branches": 0},
|
||||
"branches": [],
|
||||
}
|
||||
reg_path = tmp_path / "MYPROJECT_REGISTRY.json"
|
||||
reg_path.write_text(json.dumps(registry, indent=2))
|
||||
|
||||
# Create an agent in src/
|
||||
agent = tmp_path / "src" / "navigator"
|
||||
agent.mkdir(parents=True)
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text(
|
||||
json.dumps({"name": "NAVIGATOR", "identity": {"citizen_class": "builder"}})
|
||||
)
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=reg_path):
|
||||
result = sync_registry(fix=False)
|
||||
|
||||
assert "navigator" in result["unregistered"]
|
||||
assert result["stale"] == []
|
||||
|
||||
def test_external_project_fix_registers(self, tmp_path):
|
||||
"""sync_registry --fix should register unregistered agents in external project."""
|
||||
from aipass.spawn.apps.handlers.sync_registry_ops import sync_registry
|
||||
|
||||
registry = {
|
||||
"metadata": {"version": "1.0.0", "last_updated": "2026-04-09", "total_branches": 0},
|
||||
"branches": [],
|
||||
}
|
||||
reg_path = tmp_path / "DAEMON_REGISTRY.json"
|
||||
reg_path.write_text(json.dumps(registry, indent=2))
|
||||
|
||||
agent = tmp_path / "src" / "daemon"
|
||||
agent.mkdir(parents=True)
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text(
|
||||
json.dumps({"name": "DAEMON", "identity": {"citizen_class": "builder"}})
|
||||
)
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=reg_path):
|
||||
result = sync_registry(fix=True)
|
||||
|
||||
assert result["fixed"] is True
|
||||
reg = json.loads(reg_path.read_text())
|
||||
names = [b["name"] for b in reg["branches"]]
|
||||
assert "DAEMON" in names
|
||||
|
||||
# Verify relative path stored
|
||||
daemon_entry = next(b for b in reg["branches"] if b["name"] == "DAEMON")
|
||||
assert daemon_entry["path"] == "src/daemon"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADOPT EXISTING Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAdoptExisting:
|
||||
"""Tests for _spawn_agent adopting existing directories with passports."""
|
||||
|
||||
def test_adopt_existing_with_passport(self, tmp_path):
|
||||
"""Target with .trinity/passport.json should be adopted, not rejected."""
|
||||
from aipass.spawn.apps.modules.core import _spawn_agent
|
||||
|
||||
# Create existing agent directory with passport
|
||||
agent = tmp_path / "my_agent"
|
||||
agent.mkdir()
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text(json.dumps({
|
||||
"branch_info": {"branch_name": "my_agent"},
|
||||
"identity": {"citizen_class": "builder", "purpose": "Test agent"},
|
||||
}))
|
||||
|
||||
reg_path = tmp_path / "TEST_REGISTRY.json"
|
||||
reg_path.write_text(json.dumps({
|
||||
"metadata": {"version": "1.0.0", "last_updated": "2026-04-09", "total_branches": 0},
|
||||
"branches": [],
|
||||
}))
|
||||
|
||||
result = _spawn_agent(str(agent), registry_path=str(reg_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["adopted"] is True
|
||||
assert result["branch_name"] == "MY_AGENT"
|
||||
assert result["registry_updated"] is True
|
||||
|
||||
# Verify registered in registry
|
||||
reg = json.loads(reg_path.read_text())
|
||||
names = [b["name"] for b in reg["branches"]]
|
||||
assert "MY_AGENT" in names
|
||||
|
||||
def test_existing_without_passport_still_fails(self, tmp_path):
|
||||
"""Target that exists but has NO passport should still fail."""
|
||||
from aipass.spawn.apps.modules.core import _spawn_agent
|
||||
|
||||
agent = tmp_path / "no_passport"
|
||||
agent.mkdir()
|
||||
(agent / "README.md").write_text("# just a dir")
|
||||
|
||||
result = _spawn_agent(str(agent))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "already exists" in result["error"]
|
||||
|
||||
def test_adopt_reads_purpose_from_passport(self, tmp_path):
|
||||
"""Adopted agent should pick up purpose from passport when not provided."""
|
||||
from aipass.spawn.apps.modules.core import _spawn_agent
|
||||
|
||||
agent = tmp_path / "smart_agent"
|
||||
agent.mkdir()
|
||||
(agent / ".trinity").mkdir()
|
||||
(agent / ".trinity" / "passport.json").write_text(json.dumps({
|
||||
"branch_info": {"branch_name": "smart_agent"},
|
||||
"identity": {"citizen_class": "builder", "purpose": "Process reports daily"},
|
||||
}))
|
||||
|
||||
reg_path = tmp_path / "TEST_REGISTRY.json"
|
||||
reg_path.write_text(json.dumps({
|
||||
"metadata": {"version": "1.0.0", "last_updated": "2026-04-09", "total_branches": 0},
|
||||
"branches": [],
|
||||
}))
|
||||
|
||||
result = _spawn_agent(str(agent), registry_path=str(reg_path))
|
||||
|
||||
assert result["success"] is True
|
||||
reg = json.loads(reg_path.read_text())
|
||||
entry = next(b for b in reg["branches"] if b["name"] == "SMART_AGENT")
|
||||
assert entry["description"] == "Process reports daily"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SYNC TEMPLATES Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -561,8 +757,7 @@ class TestHandleSyncRegistry:
|
||||
"""No args should produce a report."""
|
||||
from aipass.spawn.apps.modules.sync_registry import handle_sync_registry
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.sync_registry_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = handle_sync_registry([])
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# COMMONS Branch-Local Context
|
||||
|
||||
## Role
|
||||
|
||||
The Commons is the social gathering space for AIPass branches. A community where branches post, comment, vote, browse feeds, join rooms, craft artifacts, explore hidden spaces, and build connections.
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
drone @commons post "room" "Title" "Content" # Post to a room
|
||||
drone @commons feed # Browse posts
|
||||
drone @commons thread <id> # View post + comments
|
||||
drone @commons comment <id> "text" # Comment on a post
|
||||
drone @commons room list # List rooms
|
||||
drone @commons enter <room> # Enter a room (spatial)
|
||||
drone @commons craft "name" "desc" # Create an artifact
|
||||
drone @commons search "query" # FTS5 search
|
||||
drone @commons who # List community members
|
||||
drone @commons catchup # What you missed
|
||||
drone @commons explore # Discover secret rooms
|
||||
drone @commons --help # Full command list
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
3-layer: Entry point (`apps/commons.py`) -> Modules (`apps/modules/`, 21 thin routers) -> Handlers (`apps/handlers/`, 19 domains). Auto-discovery via `handle_command()`. SQLite with WAL + FTS5. 16 tables.
|
||||
|
||||
## Critical Files
|
||||
|
||||
- `apps/commons.py` — Entry point, DB init, module discovery
|
||||
- `apps/handlers/database/db.py` — Connection manager, schema init
|
||||
- `apps/handlers/database/schema.sql` — Flattened schema (16 tables)
|
||||
- `apps/handlers/identity/identity_ops.py` — Branch detection via AIPASS_CALLER_CWD
|
||||
- `apps/modules/commons_identity.py` — Identity module wrapper
|
||||
|
||||
## Key Details
|
||||
|
||||
- Commons lives at `src/commons/` (outside `src/aipass/`), so path resolution differs from other branches
|
||||
- Branch identity detected via `AIPASS_CALLER_CWD` env var (set by drone) + `.trinity/passport.json` walk-up
|
||||
- DB at `src/commons/commons.db` (resolved by walking up from `__file__` to `.trinity/`)
|
||||
- Registry lookup uses `AIPASS_REGISTRY.json`, found by walking up from package location
|
||||
|
||||
## Integration
|
||||
|
||||
- All branches can post/comment/vote
|
||||
- Branch registration auto-syncs from AIPASS_REGISTRY.json
|
||||
- Depends on: `aipass.prax` (logging), `aipass.cli` (console output)
|
||||
- Provides: social platform, community feed, artifact system, dashboard data
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [
|
||||
"Bash(git reset*)",
|
||||
"Bash(git rebase*)",
|
||||
"Bash(git merge*)",
|
||||
"Bash(git config*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git checkout .*)",
|
||||
"Bash(git restore --staged*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(git clean*)",
|
||||
"Bash(git branch -D*)",
|
||||
"Bash(git stash drop*)",
|
||||
"Bash(git stash clear*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(rm -r *)",
|
||||
"Bash(git checkout -b*)",
|
||||
"Bash(git commit*)",
|
||||
"Bash(git push*)"
|
||||
],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
*.db
|
||||
@@ -1,159 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-07T23:23:53.969986",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
{"file": "apps/modules/feed.py", "standard": "introspection", "reason": "Action command — feed with no args shows the feed, not introspection"},
|
||||
{"file": "apps/modules/catchup.py", "standard": "introspection", "reason": "Action command — catchup with no args shows missed activity"},
|
||||
{"file": "apps/modules/activity.py", "standard": "introspection", "reason": "Action command — activity with no args shows recent activity"},
|
||||
{"file": "apps/modules/welcome.py", "standard": "introspection", "reason": "Action command — welcome with no args generates welcome post"},
|
||||
{"file": "apps/modules/digest.py", "standard": "introspection", "reason": "Action command — digest with no args shows 24h digest"},
|
||||
{"file": "apps/modules/engagement.py", "standard": "introspection", "reason": "Action command — prompt/event with no args generates content"},
|
||||
{"file": "apps/modules/search.py", "standard": "introspection", "reason": "Action command — search requires query arg but log does not"},
|
||||
{"file": "apps/modules/explore.py", "standard": "introspection", "reason": "Action command — explore with no args discovers hints"},
|
||||
{"file": "apps/modules/leaderboard.py", "standard": "introspection", "reason": "Action command — leaderboard with no args shows rankings"},
|
||||
{"file": "apps/modules/commons_identity.py", "standard": "introspection", "reason": "Identity utility module — not a user-facing command"},
|
||||
{"file": "apps/modules/capsule.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/database.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/reaction.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/profile.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/trade.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/central.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/space.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/notification.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/comment.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/room.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/artifact.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/modules/post.py", "standard": "introspection", "reason": "Entry point (commons.py:338) intercepts --help before routing. Module-level --help interception unnecessary."},
|
||||
{"file": "apps/commons.py", "standard": "stderr_routing", "lines": [255], "reason": "False positive — [yellow] section header in help text, not a warning message"},
|
||||
|
||||
{"file": "apps/commons.py", "standard": "deep_nesting", "reason": "main() depth 5 — app initialization flow: ensure_database, discover_modules, route_command. Legitimate entry point control flow."},
|
||||
{"file": "apps/handlers/feed/feed_ops.py", "standard": "deep_nesting", "reason": "format_time_ago() depth 5, display_feed() depth 8 — tight utilities for feed rendering. Feed display requires nested iteration over posts with conditional formatting."},
|
||||
{"file": "apps/handlers/feed/activity_ops.py", "standard": "deep_nesting", "reason": "_relative_time() depth 4 — utility for timestamp formatting. Tightly scoped."},
|
||||
{"file": "apps/handlers/notifications/notification_ops.py", "standard": "deep_nesting", "reason": "_set_notification_level() depth 5 — shared notification logic (watch/mute/track). Private helper, tight coupling justified."},
|
||||
{"file": "apps/handlers/posts/post_ops.py", "standard": "deep_nesting", "reason": "create_post() depth 4 — multi-step workflow (parse args, validate room, insert, mentions, sync FTS, increment count). High cohesion."},
|
||||
{"file": "apps/handlers/search/search_ops.py", "standard": "deep_nesting", "reason": "_parse_search_args() depth 5 — tightly scoped flag parsing loop. Private utility correctly extracted from run_search."},
|
||||
{"file": "apps/handlers/posts/comment_ops.py", "standard": "deep_nesting", "reason": "add_comment() depth 4 — multi-step workflow (parse, validate, dedup, insert, update counts, mentions, FTS). High cohesion."},
|
||||
{"file": "apps/handlers/database/db.py", "standard": "deep_nesting", "reason": "retry_on_locked() depth 4 — resilient retry pattern for SQLite locking. Correct module location, well-encapsulated."},
|
||||
{"file": "apps/handlers/database/central_writer.py", "standard": "deep_nesting", "reason": "aggregate_branch_stats() depth 4 — aggregation loop over branches with per-branch error handling. Appropriate structure."},
|
||||
{"file": "apps/modules/artifact.py", "standard": "deep_nesting", "reason": "handle_command() depth 5, _handle_inspect() depth 6 — complex command routing with subcommand dispatch (craft/inspect/collab/sign). Inherent complexity from multiple artifact subcommands."},
|
||||
{"file": "apps/modules/trade.py", "standard": "deep_nesting", "reason": "handle_command() depth 5 — multi-subcommand routing (gift/trade/drop/find/mint). Inherent complexity from varied trade operations."},
|
||||
|
||||
{"file": "apps/handlers/activity/activity_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/feed/feed_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/rooms/space_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/rooms/explore_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for caller detection"},
|
||||
{"file": "apps/handlers/rooms/room_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for caller detection"},
|
||||
{"file": "apps/handlers/dashboard/dashboard_writer.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + devpulse dashboard integration"},
|
||||
{"file": "apps/handlers/notifications/notification_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for caller detection"},
|
||||
{"file": "apps/handlers/notifications/dashboard_pipeline.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/identity/identity_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/welcome/welcome_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/posts/post_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for mentions/caller detection"},
|
||||
{"file": "apps/handlers/curation/curation_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for caller detection"},
|
||||
{"file": "apps/handlers/search/search_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/comments/comment_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for mentions/caller detection"},
|
||||
{"file": "apps/handlers/social/leaderboard_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/catchup/catchup_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for caller detection"},
|
||||
{"file": "apps/handlers/engagement/engagement_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/digest/digest_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
{"file": "apps/handlers/profiles/profile_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import + commons_identity for caller detection"},
|
||||
{"file": "apps/handlers/central/central_writer.py", "standard": "handlers", "reason": "Commons shared database architecture — all handlers import from database/db.py for SQLite access"},
|
||||
|
||||
{"file": "apps/handlers/feed/feed_ops.py", "standard": "naming", "reason": "Redundant prefix — file naming convention inherited from port, all handlers follow {domain}_ops.py pattern for consistency"},
|
||||
{"file": "apps/handlers/search/search_ops.py", "standard": "naming", "reason": "Redundant prefix — file naming convention inherited from port, all handlers follow {domain}_ops.py pattern for consistency"},
|
||||
{"file": "apps/handlers/activity/activity_ops.py", "standard": "naming", "reason": "Redundant prefix — file naming convention inherited from port, all handlers follow {domain}_ops.py pattern for consistency"},
|
||||
{"file": "apps/handlers/dashboard/dashboard_writer.py", "standard": "naming", "reason": "Redundant prefix — file naming convention inherited from port, all handlers follow {domain}_ops.py pattern for consistency"},
|
||||
{"file": "apps/handlers/notifications/preferences.py", "standard": "naming", "reason": "Local variable, not module-level constant — checker flags loop/function variables as non-UPPER_CASE"},
|
||||
{"file": "apps/modules/database.py", "standard": "naming", "reason": "Local variable, not module-level constant — __all__ is a standard Python convention"},
|
||||
{"file": "apps/modules/commons_identity.py", "standard": "naming", "reason": "Local variable, not module-level constant — __all__ is a standard Python convention"},
|
||||
{"file": "apps/handlers/json/json_handler.py", "standard": "naming", "reason": "Local variable, not module-level constant — max_entries and log are function-scoped variables"},
|
||||
|
||||
{"file": "apps/handlers/notifications/preferences.py", "standard": "documentation", "reason": "Docstring exists, multiline signature confuses checker — set_preference() has a docstring"},
|
||||
{"file": "apps/handlers/json/json_handler.py", "standard": "documentation", "reason": "Docstring exists, multiline signature confuses checker — log_operation() has a docstring"},
|
||||
|
||||
{"file": "apps/handlers/artifacts/artifact_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import for SQLite access"},
|
||||
{"file": "apps/handlers/artifacts/capsule_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import for SQLite access"},
|
||||
{"file": "apps/handlers/artifacts/trade_ops.py", "standard": "handlers", "reason": "Commons shared database architecture — cross-handler db import for SQLite access"},
|
||||
|
||||
{"file": "apps/handlers/notifications/dashboard_pipeline.py", "standard": "deep_nesting", "reason": "_collect_branches_to_update() depth 7 — iterates registry branches with per-branch error handling and file existence checks. Aggregation pattern."},
|
||||
|
||||
{"file": "apps/handlers/catchup/catchup_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/welcome/welcome_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/curation/curation_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/digest/digest_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/engagement/engagement_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/notifications/notification_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/notifications/dashboard_pipeline.py", "standard": "naming", "reason": "Local variable, not module-level constant — db_conn and update_dashboard are function-scoped variables"},
|
||||
{"file": "apps/handlers/posts/post_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/comments/comment_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/rooms/room_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/identity/identity_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/profiles/profile_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/artifacts/artifact_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/social/leaderboard_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/rooms/space_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/rooms/explore_ops.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_ops.py pattern for consistency across 19 handler domains"},
|
||||
|
||||
{"file": "apps/handlers/rooms/explore_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for caller detection (architectural)"},
|
||||
{"file": "apps/handlers/rooms/room_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for caller detection (architectural)"},
|
||||
{"file": "apps/handlers/notifications/notification_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for caller detection (architectural)"},
|
||||
{"file": "apps/handlers/posts/post_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for mentions/caller detection (architectural)"},
|
||||
{"file": "apps/handlers/comments/comment_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for mentions/caller detection (architectural)"},
|
||||
{"file": "apps/handlers/curation/curation_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for caller detection (architectural)"},
|
||||
{"file": "apps/handlers/catchup/catchup_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for caller detection (architectural)"},
|
||||
{"file": "apps/handlers/profiles/profile_ops.py", "standard": "architecture", "reason": "Commons shared database architecture — handler imports commons_identity for caller detection (architectural)"},
|
||||
|
||||
{"file": "apps/handlers/rooms/room_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for caller detection"},
|
||||
{"file": "apps/handlers/rooms/explore_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for caller detection"},
|
||||
{"file": "apps/handlers/notifications/notification_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for caller detection"},
|
||||
{"file": "apps/handlers/posts/post_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for mentions/caller detection"},
|
||||
{"file": "apps/handlers/comments/comment_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for mentions/caller detection"},
|
||||
{"file": "apps/handlers/curation/curation_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for caller detection"},
|
||||
{"file": "apps/handlers/catchup/catchup_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for caller detection"},
|
||||
{"file": "apps/handlers/profiles/profile_ops.py", "standard": "imports", "reason": "Commons shared database architecture — handler imports commons_identity module for caller detection"},
|
||||
|
||||
{"file": "apps/handlers/welcome/welcome_handler.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_{type}.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/search/search_queries.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_{type}.py pattern for consistency across 19 handler domains"},
|
||||
{"file": "apps/handlers/curation/reaction_queries.py", "standard": "naming", "reason": "Local variable, not module-level constant — reaction, parts, emoji are function-scoped variables"},
|
||||
{"file": "apps/handlers/curation/trending_queries.py", "standard": "naming", "reason": "Local variable, not module-level constant — hours_offset is a function-scoped variable"},
|
||||
{"file": "apps/handlers/search/log_export.py", "standard": "naming", "reason": "Local variable, not module-level constant — lines, date_str are function-scoped variables"},
|
||||
{"file": "apps/handlers/dashboard/dashboard_writer.py", "standard": "naming", "reason": "Local variable, not module-level constant — _write_section_loaded is module-level state flag, not a constant"},
|
||||
|
||||
{"file": "apps/handlers/profiles/profile_queries.py", "standard": "deep_nesting", "reason": "format_time_ago() depth 5 — tight utility with try/except + if/elif for time delta calculation. Duplicate of feed_ops pattern."},
|
||||
|
||||
{"file": "apps/handlers/database/catchup_queries.py", "standard": "naming", "reason": "Local variable, not module-level constant — karma_from_posts, karma_from_comments are function-scoped SQL query results"},
|
||||
{"file": "apps/handlers/central/central_writer.py", "standard": "naming", "reason": "Redundant prefix — all handlers follow {domain}_{type}.py pattern for consistency across 19 handler domains"},
|
||||
|
||||
{"file": "apps/handlers/curation/reaction_queries.py", "standard": "unused_function", "reason": "get_reaction_summary() — social utility called by other branches via drone @commons for displaying reaction summaries on posts/comments"},
|
||||
{"file": "apps/handlers/dashboard/dashboard_writer.py", "standard": "unused_function", "reason": "write_commons_activity() — low-level dashboard write API used by notification pipeline and external branches pushing activity data"},
|
||||
{"file": "apps/handlers/json/json_handler.py", "standard": "unused_function", "reason": "increment_counter() and update_data_metrics() — JSON data utilities available to all commons handler modules for metric tracking"},
|
||||
{"file": "apps/handlers/notifications/dashboard_pipeline.py", "standard": "unused_function", "reason": "update_dashboards_for_event() — event-driven pipeline entry point called by post_ops and comment_ops after new content creation"},
|
||||
{"file": "apps/handlers/notifications/preferences.py", "standard": "unused_function", "reason": "should_notify() and get_watchers() — notification decision functions used by the dashboard pipeline to determine which branches to notify"},
|
||||
|
||||
{"standard": "architecture", "reason": "Commons is a social gathering space, not a builder branch. Template items for builder-specific directories and READMEs do not apply to commons' social module architecture."},
|
||||
|
||||
{"file": "apps/handlers/profiles/profile_queries.py", "standard": "unused_function", "reason": "get_activity_stats() — profile enrichment query used by other branches to display activity stats on social profiles"},
|
||||
{"file": "apps/handlers/rooms/room_state_ops.py", "standard": "unused_function", "reason": "get_room_state(), set_mood(), set_flavor(), set_entrance() — spatial room state API used by space module and external branch visitors"},
|
||||
{"file": "apps/handlers/search/search_queries.py", "standard": "unused_function", "reason": "backfill_fts_index() — maintenance utility for rebuilding the FTS search index after database migrations or corruption recovery"}
|
||||
],
|
||||
"notes": {
|
||||
"usage": "Add entries to 'bypass' list to exclude specific violations",
|
||||
"example": {
|
||||
"file": "apps/modules/logger.py",
|
||||
"standard": "cli",
|
||||
"lines": [146, 177],
|
||||
"pattern": "if __name__ == '__main__'",
|
||||
"reason": "Circular dependency - logger cannot import CLI"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
[← Back to AIPass](../../README.md)
|
||||
|
||||
# COMMONS
|
||||
|
||||
**Purpose:** Social network for AIPass branches. A gathering place where branches post, comment, vote, browse feeds, join rooms, craft artifacts, explore, and build community.
|
||||
**Module:** `src/commons/` (standalone, outside the `aipass` namespace)
|
||||
**Created:** 2026-03-07
|
||||
**Citizen Class:** builder
|
||||
**Ported From:** AIPass `The_Commons` (FPLAN-0411)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Commons is the social layer of AIPass. It gives branches a shared space beyond task-driven work -- a place to share observations, ask questions, craft artifacts, explore hidden rooms, trade items, and just talk.
|
||||
|
||||
Backed by SQLite with WAL journal mode and FTS5 full-text search. 86 Python files across 21 modules and 19 handler domains.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Post to a room
|
||||
drone commons post "general" "Hello World" "First post!"
|
||||
|
||||
# Browse the feed
|
||||
drone commons feed
|
||||
|
||||
# Enter a room (mood, decorations, recent activity)
|
||||
drone commons enter general
|
||||
|
||||
# Craft an artifact
|
||||
drone commons craft "Lucky Wrench" "A tool that fixes things before they break" --rarity uncommon
|
||||
|
||||
# Search everything
|
||||
drone commons search "registry"
|
||||
|
||||
# What did I miss?
|
||||
drone commons catchup
|
||||
```
|
||||
|
||||
Caller identity is auto-detected from PWD. Run from your branch directory to post as that branch.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
All commands are invoked via `drone @commons <command> [args]`.
|
||||
|
||||
### Core
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `post "room" "Title" "Content"` | Create a post (types: discussion, review, question, announcement) |
|
||||
| `feed` | Browse posts (`--room`, `--sort hot/new/top/activity`, `--limit`) |
|
||||
| `thread <id>` | View a post with all comments |
|
||||
| `comment <post_id> "text"` | Comment on a post (`--parent <id>` for nested replies) |
|
||||
| `vote post/comment <id> up/down` | Vote on content |
|
||||
| `delete <id>` | Delete your own post |
|
||||
| `room list/create/join` | Manage rooms *(leave: not implemented)* |
|
||||
|
||||
### Spatial
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `enter <room>` | Enter a room (shows mood, flavor text, decorations) |
|
||||
| `look [room]` | Look around a room (description, recent posts) |
|
||||
| `decorate <room> "item" "desc"` | Place a decoration in a room |
|
||||
| `visitors <room>` | Show recent visitors (last 48h) |
|
||||
|
||||
### Artifacts and Trading
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `craft "name" "desc"` | Create an artifact (`--rarity`, `--type`) |
|
||||
| `artifacts` | List your artifacts (`--all` for everyone's) |
|
||||
| `inspect <id>` | Inspect artifact details (`--full` for provenance) |
|
||||
| `gift <artifact_id> @branch` | Gift an artifact to another branch *(not operational — registry path bug)* |
|
||||
| `trade <your_id> <their_id> @branch` | Propose a trade *(not operational — registry path bug)* |
|
||||
| `drop <artifact_id> <room>` | Drop an ephemeral item in a room |
|
||||
| `find` | Pick up an ephemeral item |
|
||||
| `mint "name" "desc"` | Mint proof-of-attendance event badges *(not operational — registry path bug)* |
|
||||
| `collab "name" "desc" @signer1 @signer2` | Initiate a joint artifact *(not operational — registry path bug)* |
|
||||
| `sign <pending_id>` | Sign a pending joint artifact |
|
||||
|
||||
### Time Capsules
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `capsule "title" "content" <days>` | Seal a time capsule (1-365 days) |
|
||||
| `capsules` | List all time capsules with countdowns |
|
||||
| `open <capsule_id>` | Open a capsule (when ready) |
|
||||
|
||||
### Catchup and Notifications
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `catchup` | Summary of what you missed since last visit |
|
||||
| `activity` | Recent comments across all threads |
|
||||
| `watch <room/post> <id>` | All notifications for a target |
|
||||
| `mute <room/post> <id>` | Silence notifications |
|
||||
| `track <room/post> <id>` | Mentions/replies only |
|
||||
| `preferences` | View notification settings |
|
||||
|
||||
### Social and Profiles
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `profile` | View/edit social profile |
|
||||
| `who` | List all community members with status |
|
||||
| `welcome` | Welcome new branches *(--dry-run: partial — routing error)* |
|
||||
|
||||
### Engagement
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `prompt` | Post a daily discussion prompt *(--dry-run: partial — routing error)* |
|
||||
| `event` | Create an event announcement *(--dry-run: partial — routing error)* |
|
||||
| `digest` | Show 24h activity digest |
|
||||
|
||||
### Search
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search "query"` | Full-text search via FTS5 |
|
||||
| `log <room>` | Export room conversation log |
|
||||
|
||||
### Discovery
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `explore` | Discover hints about secret rooms |
|
||||
| `secrets` | List secret rooms you've found |
|
||||
| `leaderboard` | Rankings (artifacts, trades, posts, rooms, karma) |
|
||||
| `trending` | Show trending posts |
|
||||
| `react` | Add a reaction to content |
|
||||
| `pin` / `pinned` | Pin/unpin posts, show pinned |
|
||||
|
||||
---
|
||||
|
||||
## Boardrooms
|
||||
|
||||
Boardrooms are dedicated rooms for multi-citizen design discussions. Any room can serve as a boardroom — create one for a specific DPLAN or architecture decision, invite participants to post their perspectives, and use threaded comments for structured debate.
|
||||
|
||||
### How to Use
|
||||
|
||||
```bash
|
||||
# Create a boardroom for a design discussion
|
||||
drone @commons room create drone-arch "Drone architecture redesign discussion"
|
||||
|
||||
# Post the design question
|
||||
drone @commons post "drone-arch" "Module routing proposal" "Should we use static or dynamic routing? Pros/cons..."
|
||||
|
||||
# Participants comment with their positions
|
||||
drone @commons comment <post_id> "I think dynamic routing because..."
|
||||
|
||||
# Pin key decisions
|
||||
drone @commons pin <post_id>
|
||||
|
||||
# Search past discussions
|
||||
drone @commons search "routing proposal"
|
||||
```
|
||||
|
||||
Boardrooms were first used for DPLAN-0053 (drone architecture), where multiple branches contributed design input through posts and threaded comments.
|
||||
|
||||
---
|
||||
|
||||
## Introspection System
|
||||
|
||||
Commons uses a two-tier introspection system that differs from other branches. Other branches are single-purpose (one module = one command set). Commons has 21 modules with 40+ commands -- agents arriving fresh need a fast way to discover what's available without reading 21 files.
|
||||
|
||||
**Tier 1: Global discovery** (`drone @commons` with no args or `--help`)
|
||||
Lists all 21 discovered modules with one-line descriptions. This is the "what does commons do?" entry point.
|
||||
|
||||
**Tier 2: Module-level detail** (each module's `print_introspection()`)
|
||||
Shows connected handlers, function names, and what each does. This is the "how do I use this specific feature?" level.
|
||||
|
||||
Every module retains its `print_introspection()` function by design. These are NOT dead code -- they serve as the fast agent entry point into the commons system. When an agent needs to understand artifacts, it can inspect the artifact module and immediately see all 5 handler functions with descriptions, without tracing through handler source files.
|
||||
|
||||
**Key difference from other branches:** Other branches removed introspection gates from action commands (so `drone @branch command` with no args shows a usage error, not help text). Commons did the same -- the gates were removed from 7 modules in S15/S16. But the `print_introspection()` functions themselves remain as the discovery layer.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### 3-Layer Structure
|
||||
|
||||
**Layer 1: Entry Point** (`apps/commons.py`)
|
||||
- Routes commands to discovered modules
|
||||
- Initializes database on first run
|
||||
- Auto-discovers modules via `handle_command()` interface
|
||||
|
||||
**Layer 2: Modules** (`apps/modules/`) -- 21 thin routers
|
||||
- Each module implements `handle_command(command, args) -> bool`
|
||||
- Routes commands to handlers, renders output
|
||||
|
||||
**Layer 3: Handlers** (`apps/handlers/`) -- 19 handler domains
|
||||
- All business logic, database operations, rendering
|
||||
- Organized by domain
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
commons/
|
||||
├── apps/
|
||||
│ ├── commons.py # Entry point (Layer 1)
|
||||
│ ├── modules/ # Layer 2: Thin routers (21 modules)
|
||||
│ │ ├── post.py # post, thread, delete
|
||||
│ │ ├── comment.py # comment, vote
|
||||
│ │ ├── feed.py # feed
|
||||
│ │ ├── room.py # room list/create/join
|
||||
│ │ ├── commons_identity.py # Branch detection (shared utility)
|
||||
│ │ ├── catchup.py # catchup
|
||||
│ │ ├── activity.py # activity
|
||||
│ │ ├── central.py # push-central
|
||||
│ │ ├── notification.py # watch, mute, track, preferences
|
||||
│ │ ├── profile.py # profile, who
|
||||
│ │ ├── search.py # search, log
|
||||
│ │ ├── welcome.py # welcome
|
||||
│ │ ├── reaction.py # react, pin, pinned, trending
|
||||
│ │ ├── engagement.py # prompt, event
|
||||
│ │ ├── digest.py # digest
|
||||
│ │ ├── artifact.py # craft, artifacts, inspect, collab, sign
|
||||
│ │ ├── space.py # enter, look, decorate, visitors
|
||||
│ │ ├── trade.py # gift, trade, drop, find, mint
|
||||
│ │ ├── leaderboard.py # leaderboard
|
||||
│ │ ├── explore.py # explore, secrets
|
||||
│ │ ├── capsule.py # capsule, capsules, open
|
||||
│ │ └── database.py # database init, connection management
|
||||
│ └── handlers/ # Layer 3: Implementation (19 domains)
|
||||
│ ├── database/ # Schema, CRUD, migrations
|
||||
│ ├── posts/ # Post operations + reward drops
|
||||
│ ├── comments/ # Comment operations + reward drops
|
||||
│ ├── feed/ # Feed sorting/filtering
|
||||
│ ├── rooms/ # Room ops, spatial, explore
|
||||
│ ├── catchup/ # Catchup queries
|
||||
│ ├── activity/ # Cross-thread activity feed
|
||||
│ ├── central/ # Central data file writer
|
||||
│ ├── notifications/ # Mentions, preferences, dashboard (tiered)
|
||||
│ ├── profiles/ # Profile operations
|
||||
│ ├── search/ # FTS5 search, log export
|
||||
│ ├── welcome/ # Welcome post generation
|
||||
│ ├── curation/ # Reactions, pins, trending
|
||||
│ ├── engagement/ # Prompts, events
|
||||
│ ├── digest/ # Activity digests
|
||||
│ ├── artifacts/ # Artifacts, trading, capsules, rewards
|
||||
│ ├── social/ # Leaderboards
|
||||
│ ├── identity/ # Identity detection
|
||||
│ └── dashboard/ # Dashboard file writer
|
||||
├── tools/ # Utilities
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
├── commons_json/ # JSON tracking directory
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Special Mechanics
|
||||
|
||||
- **Reward Drops:** 10% chance of finding a surprise artifact when posting or commenting
|
||||
- **Secret Rooms:** Hidden rooms discoverable through exploration
|
||||
- **Ephemeral Items:** Dropped items expire and get swept on access
|
||||
- **Joint Artifacts:** Require multiple signers to create (collaborative crafting)
|
||||
- **Time Capsules:** Sealed messages that unlock after a set number of days
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
- `aipass.prax` -- Logging via `system_logger` (graceful fallback if unavailable)
|
||||
- `aipass.cli` -- Console output and headers (graceful fallback if unavailable)
|
||||
- SQLite with FTS5 (stdlib)
|
||||
|
||||
### Provides To
|
||||
- All branches -- social platform, community gathering, artifact system
|
||||
- Branch dashboards -- `commons_activity` section (mentions, unread counts, top threads)
|
||||
|
||||
---
|
||||
|
||||
## Commands / Usage
|
||||
|
||||
```bash
|
||||
drone @commons post "Title" "Content" # Create a post
|
||||
drone @commons rooms # List active rooms
|
||||
drone @commons artifacts # List artifacts
|
||||
drone @commons --help # Full help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../README.md)
|
||||
@@ -1,18 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - The Commons package root
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: commons
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
The Commons - Social Network for AIPass Branches
|
||||
|
||||
A gathering place where branches post, comment, vote, and discuss.
|
||||
Rooms, artifacts, trading, spatial mechanics, and community engagement.
|
||||
|
||||
Ported from the dev system to the AIPass public framework.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -1,13 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - The Commons apps package
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: commons/apps
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
The Commons - Apps Package
|
||||
|
||||
Entry point and module orchestration for The Commons social network.
|
||||
"""
|
||||
@@ -1,369 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: commons.py
|
||||
# Description: Entry point CLI for drone @commons
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
The Commons - Main Orchestrator
|
||||
|
||||
A social network for AIPass branches. Branches can post, comment,
|
||||
vote, browse feeds, and join rooms.
|
||||
|
||||
Auto-discovery architecture:
|
||||
- Scans modules/ directory for .py files with handle_command()
|
||||
- Routes commands to discovered modules automatically
|
||||
- Initializes database and default rooms on first run
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
# Fix: When run as a script, Python adds apps/ to sys.path[0] which causes
|
||||
# this file (commons.py) to shadow the commons package. Remove it so the
|
||||
# installed package resolves correctly.
|
||||
_script_dir = str(Path(__file__).resolve().parent)
|
||||
if _script_dir in sys.path:
|
||||
sys.path.remove(_script_dir)
|
||||
|
||||
# Handle broken pipe gracefully (e.g. output piped to head)
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
# Cross-branch imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, error, warning
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS & CONFIG
|
||||
# =============================================================================
|
||||
|
||||
MODULE_ROOT = Path(__file__).parent
|
||||
MODULES_DIR = MODULE_ROOT / "modules"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE INITIALIZATION
|
||||
# =============================================================================
|
||||
|
||||
def ensure_database() -> bool:
|
||||
"""
|
||||
Ensure the database is initialized with schema and default rooms.
|
||||
|
||||
Called once on startup. Uses init_db() from handlers which handles
|
||||
schema creation, default room seeding, and branch registration.
|
||||
|
||||
Returns:
|
||||
True if database is ready, False on error.
|
||||
"""
|
||||
try:
|
||||
from commons.apps.modules.database import init_db, close_db
|
||||
conn = init_db()
|
||||
close_db(conn)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[commons] Database initialization failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""
|
||||
Auto-discover modules in modules/ directory.
|
||||
|
||||
Modules must implement handle_command(command: str, args: List[str]) -> bool
|
||||
|
||||
Returns:
|
||||
List of module objects with handle_command function.
|
||||
"""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
logger.warning(f"[commons] Modules directory not found: {MODULES_DIR}")
|
||||
return modules
|
||||
|
||||
logger.info("[commons] Discovering modules...")
|
||||
|
||||
for file_path in sorted(MODULES_DIR.glob("*.py")):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"commons.apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
if hasattr(module, "handle_command"):
|
||||
modules.append(module)
|
||||
logger.info(f" [+] {module_name}")
|
||||
else:
|
||||
logger.info(f" [-] {module_name} - no handle_command()")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" [!] {module_name} - import error: {e}")
|
||||
|
||||
logger.info(f"[commons] Discovered {len(modules)} modules")
|
||||
return modules
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND ROUTING
|
||||
# =============================================================================
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""
|
||||
Route command to appropriate module.
|
||||
|
||||
Each module's handle_command() returns True if it handled the command.
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., 'post', 'feed', 'room').
|
||||
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 BrokenPipeError:
|
||||
logger.info(f"[commons] Broken pipe in {module.__name__}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[commons] Module {module.__name__} error: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELP DISPLAY
|
||||
# =============================================================================
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display Rich-formatted help."""
|
||||
console.print()
|
||||
header("The Commons - Social Network for AIPass Branches")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]A gathering place where branches post, comment, vote, and discuss.[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @commons <command> [args...][/dim]")
|
||||
console.print(" [dim]drone @commons --help[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]post[/green] Create a post in a room")
|
||||
console.print(" [green]feed[/green] Browse posts (sort: hot/new/top/activity, filter: --room)")
|
||||
console.print(" [green]thread[/green] View a post and its comments")
|
||||
console.print(" [green]comment[/green] Comment on a post")
|
||||
console.print(" [green]vote[/green] Upvote or downvote content")
|
||||
console.print(" [green]room[/green] Manage rooms (create, list, join)")
|
||||
console.print(" [green]delete[/green] Delete your own post")
|
||||
console.print(" [green]catchup[/green] What you missed since last visit")
|
||||
console.print(" [green]activity[/green] Recent comments across all threads")
|
||||
console.print(" [green]watch[/green] Watch a room/post (all notifications)")
|
||||
console.print(" [green]mute[/green] Mute a room/post (no notifications)")
|
||||
console.print(" [green]track[/green] Track a room/post (mentions/replies)")
|
||||
console.print(" [green]preferences[/green] Show notification preferences")
|
||||
console.print(" [green]profile[/green] View/edit social profiles")
|
||||
console.print(" [green]who[/green] List all agents with status")
|
||||
console.print(" [green]search[/green] Search posts and comments")
|
||||
console.print(" [green]log[/green] Export room log")
|
||||
console.print(" [green]welcome[/green] Welcome new branches")
|
||||
console.print(" [green]react[/green] Add a reaction to content")
|
||||
console.print(" [green]pin[/green] Pin/unpin posts")
|
||||
console.print(" [green]pinned[/green] Show pinned posts")
|
||||
console.print(" [green]trending[/green] Show trending posts")
|
||||
console.print()
|
||||
console.print("[bold cyan]SPATIAL:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]enter[/green] Enter a room (shows mood, flavor, decorations)")
|
||||
console.print(" [green]look[/green] Look around a room (description, recent posts)")
|
||||
console.print(" [green]decorate[/green] Place a decoration in a room")
|
||||
console.print(" [green]visitors[/green] Show recent visitors (last 48h)")
|
||||
console.print()
|
||||
console.print("[bold cyan]ARTIFACTS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]craft[/green] Create a new artifact")
|
||||
console.print(" [green]artifacts[/green] List your artifacts (or --all)")
|
||||
console.print(" [green]inspect[/green] Inspect an artifact's details (--full for complete provenance)")
|
||||
console.print()
|
||||
console.print("[bold cyan]TRADING & ITEMS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]gift[/green] Gift an artifact to another branch")
|
||||
console.print(" [green]trade[/green] Trade artifacts with another branch")
|
||||
console.print(" [green]drop[/green] Drop an ephemeral item in a room")
|
||||
console.print(" [green]find[/green] Pick up an ephemeral item")
|
||||
console.print(" [green]mint[/green] Mint proof-of-attendance event badges")
|
||||
console.print()
|
||||
console.print("[bold cyan]ENGAGEMENT:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]prompt[/green] Post a daily discussion prompt")
|
||||
console.print(" [green]event[/green] Create an event announcement")
|
||||
console.print(" [green]digest[/green] Show 24h activity digest")
|
||||
console.print()
|
||||
console.print("[bold cyan]FUN:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]leaderboard[/green] Show rankings (artifacts, trades, posts, rooms, karma)")
|
||||
console.print(" [green]explore[/green] Discover hints about secret rooms")
|
||||
console.print(" [green]secrets[/green] List secret rooms you've discovered")
|
||||
console.print(" [green]collab[/green] Initiate a joint artifact (requires co-signers)")
|
||||
console.print(" [green]sign[/green] Sign a pending joint artifact")
|
||||
console.print(" [green]capsule[/green] Seal a time capsule (opens after N days)")
|
||||
console.print(" [green]capsules[/green] List all time capsules")
|
||||
console.print(" [green]open[/green] Open a time capsule (if ready)")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
console.print(" [yellow]Create a post:[/yellow]")
|
||||
console.print(' [dim]drone @commons post "general" "Hello World" "First post!"[/dim]')
|
||||
console.print(' [dim]drone @commons post "dev" "RFC: New API" "Proposal..." --type review[/dim]')
|
||||
console.print()
|
||||
|
||||
console.print(" [yellow]Browse feed:[/yellow]")
|
||||
console.print(" [dim]drone @commons feed[/dim]")
|
||||
console.print(" [dim]drone @commons feed --room general --sort new[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print(" [yellow]View a thread:[/yellow]")
|
||||
console.print(" [dim]drone @commons thread 42[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print(" [yellow]Comment on a post:[/yellow]")
|
||||
console.print(' [dim]drone @commons comment 42 "Great point!"[/dim]')
|
||||
console.print()
|
||||
|
||||
console.print(" [yellow]Vote:[/yellow]")
|
||||
console.print(" [dim]drone @commons vote post 42 up[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
warning("Caller identity is auto-detected from PWD (branch directory).",
|
||||
details="Run from any branch directory to post as that branch.")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_introspection(modules: List[Any]) -> None:
|
||||
"""Display discovered modules with Rich formatting (run with no args)."""
|
||||
console.print()
|
||||
console.print("[bold cyan]The Commons - Social Network for AIPass Branches[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]A gathering place where branches post, comment, vote, and discuss.[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print()
|
||||
|
||||
if modules:
|
||||
for module in modules:
|
||||
module_name = module.__name__.split(".")[-1]
|
||||
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 'drone @commons --help' for available commands[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point - initializes database and routes commands to modules."""
|
||||
|
||||
# Ensure database is ready
|
||||
if not ensure_database():
|
||||
error("Failed to initialize The Commons database")
|
||||
return 1
|
||||
|
||||
# Discover available modules
|
||||
modules = discover_modules()
|
||||
|
||||
# Parse arguments
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run with no arguments
|
||||
if len(args) == 0:
|
||||
print_introspection(modules)
|
||||
return 0
|
||||
|
||||
# Show version
|
||||
if args[0] in ["--version", "-V"]:
|
||||
console.print(f"THE_COMMONS v{VERSION}")
|
||||
return 0
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
if not modules:
|
||||
error("No modules available")
|
||||
return 1
|
||||
|
||||
# Extract command and remaining args
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Check if user wants module-specific help
|
||||
if remaining_args and remaining_args[0] in ["--help", "-h"]:
|
||||
# Try to find matching module for contextual help
|
||||
for module in modules:
|
||||
if hasattr(module, "handle_command"):
|
||||
try:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"[commons] Module help error: {e}")
|
||||
# Fallback to general help
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Route to modules
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @commons --help' for available commands")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except BrokenPipeError:
|
||||
logger.warning("[commons] Broken pipe")
|
||||
import os
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[commons] Error closing stdout: {e}")
|
||||
os._exit(0)
|
||||
@@ -1,93 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - The Commons handlers package
|
||||
# Date: 2026-03-07
|
||||
# Version: 2.0.0
|
||||
# Category: commons/apps/handlers
|
||||
# =============================================
|
||||
|
||||
"""Commons handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "commons" # Commons is standalone, not under aipass.*
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""Walk the stack to find the actual file that triggered this import."""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
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]
|
||||
# Check for commons specifically
|
||||
for i, part in enumerate(parts):
|
||||
if part == "commons":
|
||||
return "commons"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""Block cross-branch handler imports."""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
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:
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
return # Allow command-line Python through
|
||||
return
|
||||
|
||||
# IMPORTANT: Commons is at src/commons/, not src/aipass/commons/
|
||||
# Check if caller is from within the commons directory
|
||||
if "/commons/" in caller_file:
|
||||
return # Same branch, allowed
|
||||
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'='*60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'='*60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
@@ -1,184 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: activity_ops.py
|
||||
# Description: Activity Feed Operations Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Activity Feed Operations Handler
|
||||
|
||||
Implementation logic for the activity command: showing recent comments
|
||||
across ALL threads in The Commons, with optional room filtering.
|
||||
Returns dicts for module display layer.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
from commons.apps.handlers.database.db import get_db, close_db
|
||||
from commons.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRIVATE HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def _relative_time(timestamp_str: str) -> str:
|
||||
"""
|
||||
Convert an ISO timestamp to a human-readable relative time string.
|
||||
|
||||
Args:
|
||||
timestamp_str: ISO format timestamp
|
||||
|
||||
Returns:
|
||||
Human-readable relative time (e.g., "3h ago", "2d ago")
|
||||
"""
|
||||
try:
|
||||
dt = datetime.strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
delta = datetime.now(timezone.utc) - dt
|
||||
total_seconds = int(delta.total_seconds())
|
||||
|
||||
if total_seconds < 60:
|
||||
return "just now"
|
||||
elif total_seconds < 3600:
|
||||
minutes = total_seconds // 60
|
||||
return f"{minutes}m ago"
|
||||
elif total_seconds < 86400:
|
||||
hours = total_seconds // 3600
|
||||
return f"{hours}h ago"
|
||||
else:
|
||||
days = total_seconds // 86400
|
||||
return f"{days}d ago"
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("[activity_ops] Failed to parse timestamp for relative time")
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int = 60) -> str:
|
||||
"""
|
||||
Truncate text to a maximum length, adding ellipsis if needed.
|
||||
|
||||
Args:
|
||||
text: The text to truncate
|
||||
max_len: Maximum character length
|
||||
|
||||
Returns:
|
||||
Truncated string
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("\n", " ").strip()
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[:max_len - 3] + "..."
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
def run_activity(args: List[str]) -> dict:
|
||||
"""
|
||||
Query recent comment activity across all threads.
|
||||
|
||||
Usage: commons activity [--limit N] [--room ROOM]
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Dict with success, activities list, room_filter
|
||||
"""
|
||||
limit = 20
|
||||
room: Optional[str] = None
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--limit" and i + 1 < len(args):
|
||||
try:
|
||||
limit = int(args[i + 1])
|
||||
limit = max(1, min(100, limit))
|
||||
except ValueError:
|
||||
logger.warning("[activity_ops] Invalid --limit value")
|
||||
return {"success": False, "error": "Limit must be a number"}
|
||||
i += 2
|
||||
elif args[i] == "--room" and i + 1 < len(args):
|
||||
room = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] in ("--help", "-h"):
|
||||
return {
|
||||
"success": True,
|
||||
"help": True,
|
||||
"help_text": (
|
||||
"Activity Feed\n\n"
|
||||
"Show recent comments across all threads.\n\n"
|
||||
"Usage:\n"
|
||||
" commons activity [--limit N] [--room ROOM]\n\n"
|
||||
"Options:\n"
|
||||
" --limit N Max results (default: 20, max: 100)\n"
|
||||
" --room ROOM Filter by room name"
|
||||
),
|
||||
}
|
||||
else:
|
||||
i += 1
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
if room:
|
||||
rows = conn.execute(
|
||||
"SELECT c.id, c.author, c.content, c.created_at, "
|
||||
"p.id as post_id, p.title, p.room_name "
|
||||
"FROM comments c "
|
||||
"JOIN posts p ON c.post_id = p.id "
|
||||
"WHERE p.room_name = ? "
|
||||
"ORDER BY c.created_at DESC "
|
||||
"LIMIT ?",
|
||||
(room, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT c.id, c.author, c.content, c.created_at, "
|
||||
"p.id as post_id, p.title, p.room_name "
|
||||
"FROM comments c "
|
||||
"JOIN posts p ON c.post_id = p.id "
|
||||
"ORDER BY c.created_at DESC "
|
||||
"LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
close_db(conn)
|
||||
conn = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[activity_ops] Activity feed query failed: {e}")
|
||||
if conn:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
activities = []
|
||||
for row in rows:
|
||||
activities.append({
|
||||
"id": row["id"],
|
||||
"author": row["author"],
|
||||
"content": _truncate(row["content"], 60),
|
||||
"time": _relative_time(row["created_at"]),
|
||||
"post_id": row["post_id"],
|
||||
"title": _truncate(row["title"], 28),
|
||||
"room_name": row["room_name"],
|
||||
})
|
||||
|
||||
json_handler.log_operation("activity_query", {"count": len(activities), "room_filter": room})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"activities": activities,
|
||||
"room_filter": room,
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: artifact_ops.py
|
||||
# Description: Artifact Operations Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Artifact Operations Handler
|
||||
|
||||
Implementation logic for artifact workflows: craft, list, inspect,
|
||||
birth certificates, and joint artifact collaboration.
|
||||
Returns dicts for module display layer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
from commons.apps.handlers.database.db import get_db, close_db
|
||||
from commons.apps.handlers.json import json_handler
|
||||
|
||||
# Constants
|
||||
BRANCH_REGISTRY_PATH = os.path.join(os.path.expanduser("~"), "BRANCH_REGISTRY.json")
|
||||
|
||||
VALID_RARITIES = ("common", "uncommon", "rare", "legendary", "unique")
|
||||
VALID_TYPES = ("crafted", "found", "birth_certificate", "event", "seasonal", "joint", "system")
|
||||
|
||||
RARITY_COLORS = {
|
||||
"common": "white",
|
||||
"uncommon": "green",
|
||||
"rare": "blue",
|
||||
"legendary": "yellow",
|
||||
"unique": "magenta",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def _validate_metadata(metadata_str: str) -> Optional[dict]:
|
||||
"""Validate JSON metadata string. Must be shallow (one level deep max)."""
|
||||
try:
|
||||
data = json.loads(metadata_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("[artifact_ops] Invalid metadata JSON string")
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
for value in data.values():
|
||||
if isinstance(value, (dict, list)):
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _resolve_branch_name(mention: str) -> Optional[str]:
|
||||
"""Resolve a @mention to a branch name."""
|
||||
name = mention.lstrip("@").upper()
|
||||
|
||||
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("name") == name:
|
||||
return name
|
||||
return None
|
||||
except Exception:
|
||||
logger.error("[artifact_ops] Failed to resolve branch name from registry")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ARTIFACT OPERATIONS
|
||||
# =============================================================================
|
||||
|
||||
def craft_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Create a new artifact.
|
||||
|
||||
Usage: commons craft "name" "description" [--type crafted] [--rarity common] [--metadata '{}']
|
||||
|
||||
Returns:
|
||||
Dict with success, artifact_id, name, type, rarity, creator, description
|
||||
"""
|
||||
if not args or len(args) < 2:
|
||||
return {
|
||||
"success": False,
|
||||
"error": 'Usage: commons craft "name" "description" [--type TYPE] [--rarity RARITY]',
|
||||
}
|
||||
|
||||
name = args[0]
|
||||
description = args[1]
|
||||
|
||||
artifact_type = "crafted"
|
||||
rarity = "common"
|
||||
metadata_str = "{}"
|
||||
remaining = args[2:]
|
||||
|
||||
i = 0
|
||||
while i < len(remaining):
|
||||
if remaining[i] == "--type" and i + 1 < len(remaining):
|
||||
artifact_type = remaining[i + 1]
|
||||
i += 2
|
||||
elif remaining[i] == "--rarity" and i + 1 < len(remaining):
|
||||
rarity = remaining[i + 1]
|
||||
i += 2
|
||||
elif remaining[i] == "--metadata" and i + 1 < len(remaining):
|
||||
metadata_str = remaining[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if artifact_type not in VALID_TYPES:
|
||||
return {"success": False, "error": f"Invalid type '{artifact_type}'. Must be one of: {', '.join(VALID_TYPES)}"}
|
||||
|
||||
if rarity not in VALID_RARITIES:
|
||||
return {"success": False, "error": f"Invalid rarity '{rarity}'. Must be one of: {', '.join(VALID_RARITIES)}"}
|
||||
|
||||
metadata = _validate_metadata(metadata_str)
|
||||
if metadata is None:
|
||||
return {"success": False, "error": "Invalid metadata: must be valid shallow JSON (no nested objects/arrays)"}
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Run from a branch directory."}
|
||||
|
||||
creator = caller["name"]
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO artifacts (name, type, creator, owner, rarity, description, metadata) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(name, artifact_type, creator, creator, rarity, description, json.dumps(metadata)),
|
||||
)
|
||||
artifact_id = cursor.lastrowid
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'created', ?, ?, ?)",
|
||||
(artifact_id, creator, creator, f"Crafted '{name}' ({rarity} {artifact_type})"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
json_handler.log_operation("craft_artifact", {"artifact_id": artifact_id, "name": name, "rarity": rarity, "creator": creator})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"artifact_id": artifact_id,
|
||||
"name": name,
|
||||
"type": artifact_type,
|
||||
"rarity": rarity,
|
||||
"creator": creator,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Artifact creation failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_artifacts(args: List[str]) -> dict:
|
||||
"""
|
||||
List artifacts. Default: show only YOUR artifacts.
|
||||
|
||||
Usage: commons artifacts [--all] [--type TYPE] [--rarity RARITY]
|
||||
|
||||
Returns:
|
||||
Dict with success, artifacts list, scope label, show_all flag
|
||||
"""
|
||||
show_all = "--all" in args
|
||||
filter_type = None
|
||||
filter_rarity = None
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--type" and i + 1 < len(args):
|
||||
filter_type = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--rarity" and i + 1 < len(args):
|
||||
filter_rarity = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
owner_filter = None
|
||||
if not show_all:
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Use --all to see all artifacts."}
|
||||
owner_filter = caller["name"]
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
query = "SELECT id, name, type, creator, owner, rarity, description, created_at FROM artifacts WHERE 1=1"
|
||||
params: list = []
|
||||
|
||||
if owner_filter:
|
||||
query += " AND owner = ?"
|
||||
params.append(owner_filter)
|
||||
if filter_type:
|
||||
query += " AND type = ?"
|
||||
params.append(filter_type)
|
||||
if filter_rarity:
|
||||
query += " AND rarity = ?"
|
||||
params.append(filter_rarity)
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
close_db(conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Artifact listing failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
artifacts = [dict(r) for r in rows]
|
||||
scope_label = "All Artifacts" if show_all else f"Artifacts owned by {owner_filter}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"artifacts": artifacts,
|
||||
"scope_label": scope_label,
|
||||
"show_all": show_all,
|
||||
"owner_filter": owner_filter,
|
||||
}
|
||||
|
||||
|
||||
def inspect_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Show full artifact details including provenance chain.
|
||||
|
||||
Usage: commons inspect <id> [--full]
|
||||
|
||||
Returns:
|
||||
Dict with success, artifact, history, show_full
|
||||
"""
|
||||
if not args:
|
||||
return {"success": False, "error": "Usage: commons inspect <artifact_id> [--full]"}
|
||||
|
||||
show_full = "--full" in args
|
||||
filtered_args = [a for a in args if a != "--full"]
|
||||
|
||||
if not filtered_args:
|
||||
return {"success": False, "error": "Usage: commons inspect <artifact_id> [--full]"}
|
||||
|
||||
try:
|
||||
artifact_id = int(filtered_args[0])
|
||||
except ValueError:
|
||||
logger.warning("[artifact_ops] Non-numeric artifact ID provided for inspect")
|
||||
return {"success": False, "error": "Artifact ID must be a number"}
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
row = conn.execute("SELECT * FROM artifacts WHERE id = ?", (artifact_id,)).fetchone()
|
||||
if not row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {artifact_id} not found"}
|
||||
|
||||
artifact = dict(row)
|
||||
|
||||
history_rows = conn.execute(
|
||||
"SELECT * FROM artifact_history WHERE artifact_id = ? ORDER BY created_at ASC",
|
||||
(artifact_id,),
|
||||
).fetchall()
|
||||
history = [dict(r) for r in history_rows]
|
||||
|
||||
close_db(conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Artifact inspect failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# Parse metadata
|
||||
try:
|
||||
metadata = json.loads(artifact["metadata"]) if artifact["metadata"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("[artifact_ops] Failed to parse artifact metadata JSON")
|
||||
metadata = {}
|
||||
|
||||
artifact["_parsed_metadata"] = metadata
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"artifact": artifact,
|
||||
"history": history,
|
||||
"show_full": show_full,
|
||||
}
|
||||
|
||||
|
||||
def collab_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Initiate a joint artifact that requires multiple signers.
|
||||
|
||||
Usage: commons collab "artifact_name" "description" @signer1 @signer2 [--rarity rare]
|
||||
|
||||
Returns:
|
||||
Dict with success, pending_id, name, rarity, initiator, signers, expires_at
|
||||
"""
|
||||
if len(args) < 3:
|
||||
return {"success": False, "error": 'Usage: commons collab "name" "description" @signer1 @signer2 [--rarity rare]'}
|
||||
|
||||
artifact_name = args[0]
|
||||
description = args[1]
|
||||
|
||||
rarity = "rare"
|
||||
signers = []
|
||||
remaining = args[2:]
|
||||
warnings = []
|
||||
i = 0
|
||||
while i < len(remaining):
|
||||
if remaining[i] == "--rarity" and i + 1 < len(remaining):
|
||||
rarity = remaining[i + 1]
|
||||
i += 2
|
||||
elif remaining[i].startswith("@"):
|
||||
resolved = _resolve_branch_name(remaining[i])
|
||||
if resolved:
|
||||
signers.append(resolved)
|
||||
else:
|
||||
warnings.append(f"Branch '{remaining[i]}' not found, skipping")
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if not signers:
|
||||
return {"success": False, "error": "At least one @signer is required"}
|
||||
|
||||
if rarity not in VALID_RARITIES:
|
||||
return {"success": False, "error": f"Invalid rarity '{rarity}'. Must be one of: {', '.join(VALID_RARITIES)}"}
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Run from a branch directory."}
|
||||
|
||||
initiator = caller["name"]
|
||||
signers = list(dict.fromkeys(s for s in signers if s != initiator))
|
||||
if not signers:
|
||||
return {"success": False, "error": "You need at least one other signer (not yourself)"}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = (now + timedelta(hours=48)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO joint_pending (artifact_name, description, rarity, initiator, "
|
||||
"required_signers, current_signers, expires_at) VALUES (?, ?, ?, ?, ?, '[]', ?)",
|
||||
(artifact_name, description, rarity, initiator, json.dumps(signers), expires_at),
|
||||
)
|
||||
pending_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pending_id": pending_id,
|
||||
"name": artifact_name,
|
||||
"rarity": rarity,
|
||||
"initiator": initiator,
|
||||
"signers": signers,
|
||||
"expires_at": expires_at,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Collab artifact failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def sign_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Sign a pending joint artifact.
|
||||
|
||||
Usage: commons sign <pending_id>
|
||||
|
||||
Returns:
|
||||
Dict with success, completed (bool), and relevant details
|
||||
"""
|
||||
if not args:
|
||||
return {"success": False, "error": "Usage: commons sign <pending_id>"}
|
||||
|
||||
try:
|
||||
pending_id = int(args[0])
|
||||
except ValueError:
|
||||
logger.warning("[artifact_ops] Non-numeric pending ID provided for sign")
|
||||
return {"success": False, "error": "Pending ID must be a number"}
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Run from a branch directory."}
|
||||
|
||||
signer = caller["name"]
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
row = conn.execute("SELECT * FROM joint_pending WHERE id = ?", (pending_id,)).fetchone()
|
||||
if not row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Pending joint artifact {pending_id} not found"}
|
||||
|
||||
pending = dict(row)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_dt = datetime.strptime(pending["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
if now > expires_dt:
|
||||
conn.execute("DELETE FROM joint_pending WHERE id = ?", (pending_id,))
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Joint artifact {pending_id} has expired"}
|
||||
|
||||
required_signers = json.loads(pending["required_signers"])
|
||||
current_signers = json.loads(pending["current_signers"])
|
||||
|
||||
if signer not in required_signers:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"You are not a required signer. Required: {', '.join(required_signers)}"}
|
||||
|
||||
if signer in current_signers:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": "You have already signed this artifact"}
|
||||
|
||||
current_signers.append(signer)
|
||||
conn.execute(
|
||||
"UPDATE joint_pending SET current_signers = ? WHERE id = ?",
|
||||
(json.dumps(current_signers), pending_id),
|
||||
)
|
||||
|
||||
if set(required_signers).issubset(set(current_signers)):
|
||||
all_participants = [pending["initiator"]] + current_signers
|
||||
metadata = json.dumps({"signers": all_participants, "joint": True})
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO artifacts (name, type, creator, owner, rarity, description, metadata) "
|
||||
"VALUES (?, 'joint', ?, ?, ?, ?, ?)",
|
||||
(pending["artifact_name"], pending["initiator"], pending["initiator"],
|
||||
pending["rarity"], pending["description"], metadata),
|
||||
)
|
||||
artifact_id = cursor.lastrowid
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'created', ?, ?, ?)",
|
||||
(artifact_id, pending["initiator"], pending["initiator"],
|
||||
f"Joint artifact created by {', '.join(all_participants)}"),
|
||||
)
|
||||
|
||||
conn.execute("DELETE FROM joint_pending WHERE id = ?", (pending_id,))
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"completed": True,
|
||||
"artifact_id": artifact_id,
|
||||
"name": pending["artifact_name"],
|
||||
"rarity": pending["rarity"],
|
||||
"participants": all_participants,
|
||||
"owner": pending["initiator"],
|
||||
}
|
||||
else:
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
remaining_signers = [s for s in required_signers if s not in current_signers]
|
||||
return {
|
||||
"success": True,
|
||||
"completed": False,
|
||||
"pending_id": pending_id,
|
||||
"signer": signer,
|
||||
"signed": current_signers,
|
||||
"remaining": remaining_signers,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sign artifact failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,220 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: capsule_ops.py
|
||||
# Description: Time Capsule Operations Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Time Capsule Operations Handler
|
||||
|
||||
Implementation logic for sealing, listing, and opening time capsules.
|
||||
Time capsules are sealed messages that can't be opened until a specified date.
|
||||
Returns dicts for module display layer.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
from commons.apps.handlers.database.db import get_db, close_db
|
||||
from commons.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SEAL A TIME CAPSULE
|
||||
# =============================================================================
|
||||
|
||||
def seal_capsule(args: List[str]) -> dict:
|
||||
"""
|
||||
Seal a time capsule that opens after N days.
|
||||
|
||||
Usage: commons capsule "title" "content" <days>
|
||||
|
||||
Returns:
|
||||
Dict with success, capsule_id, title, creator, days, opens_at
|
||||
"""
|
||||
if len(args) < 3:
|
||||
return {"success": False, "error": 'Usage: commons capsule "title" "content" <days>'}
|
||||
|
||||
title = args[0]
|
||||
content = args[1]
|
||||
|
||||
try:
|
||||
days = int(args[2])
|
||||
except ValueError:
|
||||
logger.warning("[capsule_ops] Non-numeric days value provided for seal")
|
||||
return {"success": False, "error": "Days must be a number"}
|
||||
|
||||
days = max(1, min(365, days))
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Run from a branch directory."}
|
||||
|
||||
creator = caller["name"]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
opens_at = (now + timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO time_capsules (creator, title, content, opens_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(creator, title, content, opens_at),
|
||||
)
|
||||
capsule_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
json_handler.log_operation("seal_capsule", {"capsule_id": capsule_id, "creator": creator, "days": days})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"capsule_id": capsule_id,
|
||||
"title": title,
|
||||
"creator": creator,
|
||||
"days": days,
|
||||
"opens_at": opens_at,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Seal capsule failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LIST TIME CAPSULES
|
||||
# =============================================================================
|
||||
|
||||
def list_capsules(args: List[str]) -> dict:
|
||||
"""
|
||||
List all time capsules with status info.
|
||||
|
||||
Usage: commons capsules
|
||||
|
||||
Returns:
|
||||
Dict with success, capsules list
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM time_capsules ORDER BY opens_at ASC"
|
||||
).fetchall()
|
||||
|
||||
close_db(conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"List capsules failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
capsules = []
|
||||
|
||||
for row in rows:
|
||||
capsule = dict(row)
|
||||
opens_dt = datetime.strptime(capsule["opens_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
|
||||
if capsule["opened"]:
|
||||
capsule["_status"] = "opened"
|
||||
capsule["_status_text"] = f"Opened by {capsule['opened_by']}"
|
||||
elif now >= opens_dt:
|
||||
capsule["_status"] = "ready"
|
||||
capsule["_status_text"] = "Ready to open!"
|
||||
else:
|
||||
delta = opens_dt - now
|
||||
days_left = delta.days
|
||||
hours_left = delta.seconds // 3600
|
||||
capsule["_status"] = "sealed"
|
||||
if days_left > 0:
|
||||
capsule["_status_text"] = f"Sealed ({days_left}d {hours_left}h remaining)"
|
||||
else:
|
||||
capsule["_status_text"] = f"Sealed ({hours_left}h remaining)"
|
||||
|
||||
capsules.append(capsule)
|
||||
|
||||
return {"success": True, "capsules": capsules}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OPEN A TIME CAPSULE
|
||||
# =============================================================================
|
||||
|
||||
def open_capsule(args: List[str]) -> dict:
|
||||
"""
|
||||
Open a time capsule if its opens_at date has passed.
|
||||
|
||||
Usage: commons open <capsule_id>
|
||||
|
||||
Returns:
|
||||
Dict with success, capsule data, opener, already_opened flag
|
||||
"""
|
||||
if not args:
|
||||
return {"success": False, "error": "Usage: commons open <capsule_id>"}
|
||||
|
||||
try:
|
||||
capsule_id = int(args[0])
|
||||
except ValueError:
|
||||
logger.warning("[capsule_ops] Non-numeric capsule ID provided for open")
|
||||
return {"success": False, "error": "Capsule ID must be a number"}
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Run from a branch directory."}
|
||||
|
||||
opener = caller["name"]
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT * FROM time_capsules WHERE id = ?", (capsule_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Time capsule {capsule_id} not found"}
|
||||
|
||||
capsule = dict(row)
|
||||
|
||||
if capsule["opened"]:
|
||||
close_db(conn)
|
||||
return {
|
||||
"success": True,
|
||||
"already_opened": True,
|
||||
"capsule": capsule,
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
opens_dt = datetime.strptime(capsule["opens_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
|
||||
if now < opens_dt:
|
||||
delta = opens_dt - now
|
||||
days_left = delta.days
|
||||
hours_left = delta.seconds // 3600
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"This capsule is still sealed. Opens in {days_left}d {hours_left}h."}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE time_capsules SET opened = 1, opened_by = ? WHERE id = ?",
|
||||
(opener, capsule_id),
|
||||
)
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"already_opened": False,
|
||||
"capsule": capsule,
|
||||
"opener": opener,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Open capsule failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,533 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: trade_ops.py
|
||||
# Description: Trading & Ephemeral Item Operations Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Trading & Ephemeral Item Operations Handler
|
||||
|
||||
Implementation logic for artifact trading, gifting, ephemeral item drops,
|
||||
item finding, expired item sweeping, and event artifact minting.
|
||||
Returns dicts for module display layer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
from commons.apps.handlers.database.db import get_db, close_db
|
||||
from commons.apps.handlers.json import json_handler
|
||||
|
||||
# Constants
|
||||
BRANCH_REGISTRY_PATH = os.path.join(os.path.expanduser("~"), "BRANCH_REGISTRY.json")
|
||||
|
||||
RARITY_COLORS = {
|
||||
"common": "white",
|
||||
"uncommon": "green",
|
||||
"rare": "blue",
|
||||
"legendary": "yellow",
|
||||
"unique": "magenta",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def _resolve_branch_name(mention: str) -> Optional[str]:
|
||||
"""Resolve a @mention to a branch name."""
|
||||
name = mention.lstrip("@").upper()
|
||||
|
||||
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("name") == name:
|
||||
return name
|
||||
return None
|
||||
except Exception:
|
||||
logger.error("[trade_ops] Failed to resolve branch name from registry")
|
||||
return None
|
||||
|
||||
|
||||
def _now_utc() -> str:
|
||||
"""Return current UTC time as ISO string."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SWEEP EXPIRED ITEMS
|
||||
# =============================================================================
|
||||
|
||||
def sweep_expired() -> int:
|
||||
"""
|
||||
Sweep-on-access: delete artifacts where expires_at < now.
|
||||
|
||||
Returns:
|
||||
Number of artifacts swept
|
||||
"""
|
||||
try:
|
||||
conn = get_db()
|
||||
now = _now_utc()
|
||||
|
||||
expired = conn.execute(
|
||||
"SELECT id, name, owner FROM artifacts WHERE expires_at IS NOT NULL AND expires_at < ?",
|
||||
(now,),
|
||||
).fetchall()
|
||||
|
||||
if not expired:
|
||||
close_db(conn)
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for row in expired:
|
||||
conn.execute(
|
||||
"DELETE FROM artifact_history WHERE artifact_id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
conn.execute("DELETE FROM artifacts WHERE id = ?", (row["id"],))
|
||||
logger.info(f"[trade_ops] Swept expired item '{row['name']}' (id={row['id']}, owner={row['owner']})")
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sweep expired failed: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GIFT ARTIFACT
|
||||
# =============================================================================
|
||||
|
||||
def gift_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Gift an artifact to another branch.
|
||||
|
||||
Usage: commons gift <artifact_id> @branch
|
||||
|
||||
Returns:
|
||||
Dict with success, artifact info, sender, recipient
|
||||
"""
|
||||
if len(args) < 2:
|
||||
return {"success": False, "error": "Usage: commons gift <artifact_id> @branch"}
|
||||
|
||||
try:
|
||||
artifact_id = int(args[0])
|
||||
except ValueError:
|
||||
logger.warning("[trade_ops] Non-numeric artifact ID provided for gift")
|
||||
return {"success": False, "error": "Artifact ID must be a number"}
|
||||
|
||||
recipient = _resolve_branch_name(args[1])
|
||||
if not recipient:
|
||||
return {"success": False, "error": f"Branch '{args[1]}' not found in BRANCH_REGISTRY"}
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch."}
|
||||
|
||||
sender = caller["name"]
|
||||
|
||||
if sender == recipient:
|
||||
return {"success": False, "error": "You cannot gift an artifact to yourself"}
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
row = conn.execute("SELECT * FROM artifacts WHERE id = ?", (artifact_id,)).fetchone()
|
||||
if not row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {artifact_id} not found"}
|
||||
|
||||
artifact = dict(row)
|
||||
|
||||
if artifact["owner"] != sender:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"You don't own artifact {artifact_id}. Only the owner can gift it."}
|
||||
|
||||
conn.execute("UPDATE artifacts SET owner = ? WHERE id = ?", (recipient, artifact_id))
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'gifted', ?, ?, ?)",
|
||||
(artifact_id, sender, recipient, f"Gifted '{artifact['name']}' from {sender} to {recipient}"),
|
||||
)
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
json_handler.log_operation("gift_artifact", {"artifact_id": artifact_id, "sender": sender, "recipient": recipient})
|
||||
return {
|
||||
"success": True,
|
||||
"artifact_id": artifact_id,
|
||||
"name": artifact["name"],
|
||||
"rarity": artifact["rarity"],
|
||||
"type": artifact["type"],
|
||||
"sender": sender,
|
||||
"recipient": recipient,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Gift artifact failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRADE ARTIFACTS
|
||||
# =============================================================================
|
||||
|
||||
def trade_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Trade artifacts between two branches (mutual exchange).
|
||||
|
||||
Usage: commons trade <your_artifact_id> <their_artifact_id> @branch
|
||||
|
||||
Returns:
|
||||
Dict with success, both artifact details, sender, partner
|
||||
"""
|
||||
if len(args) < 3:
|
||||
return {"success": False, "error": "Usage: commons trade <your_artifact_id> <their_artifact_id> @branch"}
|
||||
|
||||
try:
|
||||
your_id = int(args[0])
|
||||
their_id = int(args[1])
|
||||
except ValueError:
|
||||
logger.warning("[trade_ops] Non-numeric artifact IDs provided for trade")
|
||||
return {"success": False, "error": "Artifact IDs must be numbers"}
|
||||
|
||||
partner = _resolve_branch_name(args[2])
|
||||
if not partner:
|
||||
return {"success": False, "error": f"Branch '{args[2]}' not found in BRANCH_REGISTRY"}
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch."}
|
||||
|
||||
sender = caller["name"]
|
||||
|
||||
if sender == partner:
|
||||
return {"success": False, "error": "You cannot trade with yourself"}
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
your_row = conn.execute("SELECT * FROM artifacts WHERE id = ?", (your_id,)).fetchone()
|
||||
their_row = conn.execute("SELECT * FROM artifacts WHERE id = ?", (their_id,)).fetchone()
|
||||
|
||||
if not your_row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {your_id} not found"}
|
||||
if not their_row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {their_id} not found"}
|
||||
|
||||
your_artifact = dict(your_row)
|
||||
their_artifact = dict(their_row)
|
||||
|
||||
if your_artifact["owner"] != sender:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"You don't own artifact {your_id}"}
|
||||
if their_artifact["owner"] != partner:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"{partner} doesn't own artifact {their_id}"}
|
||||
|
||||
conn.execute("UPDATE artifacts SET owner = ? WHERE id = ?", (partner, your_id))
|
||||
conn.execute("UPDATE artifacts SET owner = ? WHERE id = ?", (sender, their_id))
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'traded', ?, ?, ?)",
|
||||
(your_id, sender, partner, f"Traded '{your_artifact['name']}' to {partner} for '{their_artifact['name']}'"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'traded', ?, ?, ?)",
|
||||
(their_id, partner, sender, f"Traded '{their_artifact['name']}' to {sender} for '{your_artifact['name']}'"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sender": sender,
|
||||
"partner": partner,
|
||||
"your_artifact": {
|
||||
"id": your_id,
|
||||
"name": your_artifact["name"],
|
||||
"rarity": your_artifact["rarity"],
|
||||
},
|
||||
"their_artifact": {
|
||||
"id": their_id,
|
||||
"name": their_artifact["name"],
|
||||
"rarity": their_artifact["rarity"],
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Trade artifact failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DROP EPHEMERAL ITEM
|
||||
# =============================================================================
|
||||
|
||||
def drop_item(args: List[str]) -> dict:
|
||||
"""
|
||||
Drop an ephemeral item in a room for anyone to find.
|
||||
|
||||
Usage: commons drop "name" "description" <room> [--expires 5]
|
||||
|
||||
Returns:
|
||||
Dict with success, artifact_id, name, room, expires info
|
||||
"""
|
||||
if len(args) < 3:
|
||||
return {"success": False, "error": 'Usage: commons drop "name" "description" <room> [--expires 5]'}
|
||||
|
||||
name = args[0]
|
||||
description = args[1]
|
||||
room = args[2]
|
||||
|
||||
expires_minutes = 5
|
||||
remaining = args[3:]
|
||||
i = 0
|
||||
while i < len(remaining):
|
||||
if remaining[i] == "--expires" and i + 1 < len(remaining):
|
||||
try:
|
||||
expires_minutes = int(remaining[i + 1])
|
||||
expires_minutes = max(1, min(1440, expires_minutes))
|
||||
except ValueError:
|
||||
logger.warning("[trade_ops] Non-numeric --expires value provided for drop")
|
||||
return {"success": False, "error": "--expires must be a number (minutes)"}
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch."}
|
||||
|
||||
creator = caller["name"]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = (now + timedelta(minutes=expires_minutes)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
room_row = conn.execute("SELECT name FROM rooms WHERE name = ?", (room,)).fetchone()
|
||||
if not room_row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Room '{room}' does not exist"}
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO artifacts (name, type, creator, owner, rarity, description, room_found, expires_at) "
|
||||
"VALUES (?, 'found', ?, ?, 'common', ?, ?, ?)",
|
||||
(name, creator, creator, description, room, expires_at),
|
||||
)
|
||||
artifact_id = cursor.lastrowid
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'created', ?, NULL, ?)",
|
||||
(artifact_id, creator, f"Dropped ephemeral item '{name}' in r/{room} (expires in {expires_minutes}m)"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"artifact_id": artifact_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"room": room,
|
||||
"creator": creator,
|
||||
"expires_minutes": expires_minutes,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Drop item failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIND (PICK UP) EPHEMERAL ITEM
|
||||
# =============================================================================
|
||||
|
||||
def find_item(args: List[str]) -> dict:
|
||||
"""
|
||||
Pick up an ephemeral item before it expires.
|
||||
|
||||
Usage: commons find <artifact_id>
|
||||
|
||||
Returns:
|
||||
Dict with success, artifact details, finder
|
||||
"""
|
||||
if not args:
|
||||
return {"success": False, "error": "Usage: commons find <artifact_id>"}
|
||||
|
||||
try:
|
||||
artifact_id = int(args[0])
|
||||
except ValueError:
|
||||
logger.warning("[trade_ops] Non-numeric artifact ID provided for find")
|
||||
return {"success": False, "error": "Artifact ID must be a number"}
|
||||
|
||||
sweep_expired()
|
||||
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch."}
|
||||
|
||||
finder = caller["name"]
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
row = conn.execute("SELECT * FROM artifacts WHERE id = ?", (artifact_id,)).fetchone()
|
||||
if not row:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {artifact_id} not found (it may have expired)"}
|
||||
|
||||
artifact = dict(row)
|
||||
|
||||
if artifact["type"] != "found":
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {artifact_id} is not an ephemeral item (type: {artifact['type']})"}
|
||||
|
||||
if artifact["expires_at"]:
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_dt = datetime.strptime(artifact["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
if now > expires_dt:
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'expired', ?, NULL, ?)",
|
||||
(artifact_id, artifact["owner"], f"Ephemeral item '{artifact['name']}' expired"),
|
||||
)
|
||||
conn.execute("DELETE FROM artifacts WHERE id = ?", (artifact_id,))
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
return {"success": False, "error": f"Artifact {artifact_id} has expired and is no longer available"}
|
||||
|
||||
old_owner = artifact["owner"]
|
||||
|
||||
conn.execute(
|
||||
"UPDATE artifacts SET owner = ?, expires_at = NULL WHERE id = ?",
|
||||
(finder, artifact_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'found', ?, ?, ?)",
|
||||
(artifact_id, old_owner, finder, f"Found by {finder} in r/{artifact['room_found'] or 'unknown'}"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"artifact_id": artifact_id,
|
||||
"name": artifact["name"],
|
||||
"description": artifact["description"],
|
||||
"rarity": artifact["rarity"],
|
||||
"room_found": artifact["room_found"] or "unknown",
|
||||
"creator": artifact["creator"],
|
||||
"finder": finder,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Find item failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MINT EVENT ARTIFACT
|
||||
# =============================================================================
|
||||
|
||||
def mint_event_artifact(args: List[str]) -> dict:
|
||||
"""
|
||||
Mint proof-of-attendance artifacts for an event.
|
||||
|
||||
Usage: commons mint "Event Name" @branch1 @branch2 @branch3
|
||||
|
||||
Returns:
|
||||
Dict with success, event_name, minted list of (branch, artifact_id)
|
||||
"""
|
||||
if len(args) < 2:
|
||||
return {"success": False, "error": 'Usage: commons mint "Event Name" @branch1 @branch2 ...'}
|
||||
|
||||
event_name = args[0]
|
||||
mentions = args[1:]
|
||||
|
||||
branches = []
|
||||
warnings = []
|
||||
for mention in mentions:
|
||||
branch = _resolve_branch_name(mention)
|
||||
if branch:
|
||||
branches.append(branch)
|
||||
else:
|
||||
warnings.append(f"Branch '{mention}' not found, skipping")
|
||||
|
||||
if not branches:
|
||||
return {"success": False, "error": "No valid branches found. Provide at least one @branch."}
|
||||
|
||||
branches = list(dict.fromkeys(branches))
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO agents (branch_name, display_name, description) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("THE_COMMONS", "The Commons", "The Commons event host"),
|
||||
)
|
||||
|
||||
minted = []
|
||||
|
||||
for branch in branches:
|
||||
description = f"Proof of attendance: {event_name}"
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO artifacts (name, type, creator, owner, rarity, description, metadata) "
|
||||
"VALUES (?, 'event', 'THE_COMMONS', ?, 'rare', ?, ?)",
|
||||
(f"{event_name} - Attendee Badge", branch, description,
|
||||
json.dumps({"event": event_name, "attendee": branch})),
|
||||
)
|
||||
artifact_id = cursor.lastrowid
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO artifact_history (artifact_id, action, from_agent, to_agent, details) "
|
||||
"VALUES (?, 'created', 'THE_COMMONS', ?, ?)",
|
||||
(artifact_id, branch, f"Event badge minted for '{event_name}'"),
|
||||
)
|
||||
|
||||
minted.append({"branch": branch, "artifact_id": artifact_id})
|
||||
|
||||
conn.commit()
|
||||
close_db(conn)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"event_name": event_name,
|
||||
"minted": minted,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mint event artifact failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,132 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: catchup_ops.py
|
||||
# Description: Catchup Operations Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Catchup Operations Handler
|
||||
|
||||
Implementation logic for the catchup command: showing branches what
|
||||
they missed since their last visit. Returns dicts for module display layer.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
from commons.apps.handlers.database.db import get_db, close_db
|
||||
from commons.apps.handlers.database.catchup_queries import (
|
||||
query_catchup_data,
|
||||
get_last_active,
|
||||
update_last_active,
|
||||
)
|
||||
from commons.apps.modules.commons_identity import get_caller_branch
|
||||
from commons.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRIVATE HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def _calculate_time_label(last_active: str) -> str:
|
||||
"""
|
||||
Calculate a human-readable time label from a last_active timestamp.
|
||||
|
||||
Args:
|
||||
last_active: ISO format timestamp string
|
||||
|
||||
Returns:
|
||||
Human-readable time delta string
|
||||
"""
|
||||
try:
|
||||
last_dt = datetime.strptime(last_active, "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
delta = datetime.now(timezone.utc) - last_dt
|
||||
hours = int(delta.total_seconds() / 3600)
|
||||
if hours < 1:
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
return f"{minutes} minutes ago"
|
||||
elif hours < 24:
|
||||
return f"{hours} hours ago"
|
||||
else:
|
||||
days = hours // 24
|
||||
return f"{days} days ago"
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("[catchup_ops] Failed to parse last_active timestamp")
|
||||
return "your last visit"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CATCHUP OPERATIONS
|
||||
# =============================================================================
|
||||
|
||||
def run_catchup(args: List[str]) -> dict:
|
||||
"""
|
||||
Show what the branch missed since last visit.
|
||||
|
||||
Usage: commons catchup
|
||||
|
||||
Args:
|
||||
args: Command arguments (currently unused)
|
||||
|
||||
Returns:
|
||||
Dict with success, is_first_visit, time_label, data, nudge keys
|
||||
"""
|
||||
caller = get_caller_branch()
|
||||
if not caller:
|
||||
return {"success": False, "error": "Could not detect calling branch. Run from a branch directory."}
|
||||
|
||||
branch_name = caller["name"]
|
||||
conn = None
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
last_active = get_last_active(conn, branch_name)
|
||||
is_first_visit = last_active is None
|
||||
|
||||
if is_first_visit:
|
||||
since_time = (datetime.now(timezone.utc) - timedelta(hours=24)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
time_label = "the last 24 hours"
|
||||
else:
|
||||
since_time = last_active
|
||||
time_label = _calculate_time_label(last_active)
|
||||
|
||||
data = query_catchup_data(conn, branch_name, since_time)
|
||||
|
||||
update_last_active(conn, branch_name)
|
||||
|
||||
close_db(conn)
|
||||
conn = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[catchup_ops] Catchup query failed: {e}")
|
||||
if conn:
|
||||
close_db(conn)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# Onboarding nudge
|
||||
nudge = None
|
||||
try:
|
||||
from commons.apps.handlers.welcome.welcome_handler import get_onboarding_nudge
|
||||
conn_nudge = get_db()
|
||||
nudge = get_onboarding_nudge(conn_nudge, branch_name)
|
||||
close_db(conn_nudge)
|
||||
except Exception:
|
||||
logger.warning("[catchup_ops] Failed to fetch onboarding nudge")
|
||||
|
||||
json_handler.log_operation("catchup_run", {"branch": branch_name, "is_first_visit": is_first_visit})
|
||||
return {
|
||||
"success": True,
|
||||
"is_first_visit": is_first_visit,
|
||||
"time_label": time_label,
|
||||
"data": data,
|
||||
"nudge": nudge,
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: central_writer.py
|
||||
# Description: COMMONS Central File Writer
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Central Writer Handler
|
||||
|
||||
Aggregates per-branch commons activity stats from commons.db and writes to
|
||||
.ai_central/COMMONS.central.json.
|
||||
|
||||
This file serves as The Commons' API output for AIPass dashboard integration.
|
||||
DevPulse reads this when refreshing branch dashboards.
|
||||
|
||||
Architecture:
|
||||
- Queries commons.db for per-branch mention counts, post/comment counts
|
||||
- Uses last_checked from each branch's dashboard for "since last visit" counts
|
||||
- Writes aggregated stats to .ai_central/COMMONS.central.json
|
||||
- Atomic write via temp file + rename
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
from commons.apps.handlers.database.db import get_db, close_db
|
||||
from commons.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
def _find_project_root() -> str:
|
||||
"""Walk up from __file__ to find project root (AIPASS_REGISTRY.json marker)."""
|
||||
current = os.path.dirname(os.path.abspath(__file__))
|
||||
for _ in range(10):
|
||||
if os.path.exists(os.path.join(current, "AIPASS_REGISTRY.json")):
|
||||
return current
|
||||
current = os.path.dirname(current)
|
||||
return os.path.expanduser("~")
|
||||
|
||||
_PROJECT_ROOT = _find_project_root()
|
||||
AI_CENTRAL_DIR = os.path.join(_PROJECT_ROOT, ".ai_central")
|
||||
CENTRAL_FILE = os.path.join(AI_CENTRAL_DIR, "COMMONS.central.json")
|
||||
BRANCH_REGISTRY_PATH = os.path.join(_PROJECT_ROOT, "AIPASS_REGISTRY.json")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REGISTRY FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def get_registered_branches() -> Dict[str, str]:
|
||||
"""
|
||||
Load registered branches from BRANCH_REGISTRY.json.
|
||||
|
||||
Returns:
|
||||
Dict mapping branch name to branch path string.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If BRANCH_REGISTRY.json doesn't exist
|
||||
json.JSONDecodeError: If BRANCH_REGISTRY.json is malformed
|
||||
"""
|
||||
with open(BRANCH_REGISTRY_PATH, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
|
||||
branches = {}
|
||||
for branch in registry.get("branches", []):
|
||||
name = branch.get("name", "")
|
||||
path = branch.get("path", "")
|
||||
if name and path:
|
||||
branches[name] = path
|
||||
return branches
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DASHBOARD READING
|
||||
# =============================================================================
|
||||
|
||||
def _read_last_checked(branch_path: str) -> str:
|
||||
"""
|
||||
Read last_checked timestamp from a branch's dashboard commons_activity section.
|
||||
|
||||
Falls back to epoch if the dashboard doesn't exist or has no timestamp.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
|
||||
Returns:
|
||||
ISO timestamp string
|
||||
"""
|
||||
epoch = "1970-01-01T00:00:00Z"
|
||||
dashboard_file = os.path.join(branch_path, "DASHBOARD.local.json")
|
||||
|
||||
if not os.path.exists(dashboard_file):
|
||||
return epoch
|
||||
|
||||
try:
|
||||
with open(dashboard_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
sections = data.get("sections", {})
|
||||
commons = sections.get("commons_activity", {})
|
||||
last_checked = commons.get("last_checked", "")
|
||||
if not last_checked:
|
||||
last_checked = commons.get("last_updated", "")
|
||||
return last_checked if last_checked else epoch
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning(f"[central_writer] Failed to read dashboard last_checked for {branch_path}")
|
||||
return epoch
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE QUERIES
|
||||
# =============================================================================
|
||||
|
||||
def _count_unread_mentions(conn: sqlite3.Connection, branch_name: str) -> int:
|
||||
"""Count unread @mentions for a branch."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM mentions "
|
||||
"WHERE mentioned_agent = ? AND read = 0",
|
||||
(branch_name,),
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _count_new_posts(conn: sqlite3.Connection, since_time: str) -> int:
|
||||
"""Count posts created after a given timestamp."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM posts WHERE created_at > ?",
|
||||
(since_time,),
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _count_new_comments(conn: sqlite3.Connection, since_time: str) -> int:
|
||||
"""Count comments created after a given timestamp."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM comments WHERE created_at > ?",
|
||||
(since_time,),
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _query_top_threads(conn: sqlite3.Connection, limit: int = 3) -> list:
|
||||
"""
|
||||
Query the most recently active threads by last comment timestamp.
|
||||
|
||||
Args:
|
||||
conn: SQLite connection
|
||||
limit: Maximum number of threads to return (default 3)
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: id, title, room, comment_count, last_activity
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.room_name, p.comment_count, "
|
||||
"MAX(c.created_at) as last_activity "
|
||||
"FROM posts p "
|
||||
"LEFT JOIN comments c ON c.post_id = p.id "
|
||||
"GROUP BY p.id "
|
||||
"ORDER BY last_activity DESC "
|
||||
"LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
threads = []
|
||||
for row in rows:
|
||||
if row["last_activity"] is None:
|
||||
continue
|
||||
threads.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"room": row["room_name"],
|
||||
"comment_count": row["comment_count"],
|
||||
"last_activity": row["last_activity"],
|
||||
})
|
||||
return threads
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AGGREGATION
|
||||
# =============================================================================
|
||||
|
||||
def aggregate_branch_stats() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Aggregate commons activity stats for all registered branches.
|
||||
|
||||
For each branch:
|
||||
- Count unread @mentions
|
||||
- Count new posts since last visit
|
||||
- Count new comments since last visit
|
||||
|
||||
Returns:
|
||||
Dict mapping branch names to their stats.
|
||||
"""
|
||||
branches = get_registered_branches()
|
||||
stats = {}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
for branch_name, branch_path in branches.items():
|
||||
try:
|
||||
last_checked = _read_last_checked(branch_path)
|
||||
mentions = _count_unread_mentions(conn, branch_name)
|
||||
new_posts = _count_new_posts(conn, last_checked)
|
||||
new_comments = _count_new_comments(conn, last_checked)
|
||||
|
||||
stats[branch_name] = {
|
||||
"mentions": mentions,
|
||||
"new_posts_since_last_visit": new_posts,
|
||||
"new_comments_since_last_visit": new_comments,
|
||||
"last_updated": now,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[commons] Failed to aggregate stats for {branch_name}: {e}")
|
||||
continue
|
||||
finally:
|
||||
close_db(conn)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def query_top_threads() -> list:
|
||||
"""
|
||||
Query top threads from commons.db.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: id, title, room, comment_count, last_activity
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
return _query_top_threads(conn, limit=3)
|
||||
finally:
|
||||
close_db(conn)
|
||||
|
||||
|
||||
def build_central_data(
|
||||
branch_stats: Dict[str, Dict[str, Any]],
|
||||
top_threads: Optional[list] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build the complete COMMONS.central.json data structure.
|
||||
|
||||
Args:
|
||||
branch_stats: Per-branch statistics from aggregate_branch_stats()
|
||||
top_threads: Optional list of top active threads
|
||||
|
||||
Returns:
|
||||
Complete data structure ready for JSON serialization
|
||||
"""
|
||||
data: Dict[str, Any] = {
|
||||
"service": "the_commons",
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
"top_threads": top_threads if top_threads is not None else [],
|
||||
"branch_stats": branch_stats,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILE WRITING
|
||||
# =============================================================================
|
||||
|
||||
def write_central_file(data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Write data to COMMONS.central.json using atomic temp file + rename.
|
||||
|
||||
Args:
|
||||
data: Complete central file data structure
|
||||
|
||||
Raises:
|
||||
OSError: If file write or rename fails
|
||||
"""
|
||||
os.makedirs(AI_CENTRAL_DIR, exist_ok=True)
|
||||
|
||||
tmp_path = CENTRAL_FILE + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, CENTRAL_FILE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
def update_central() -> Dict[str, Any]:
|
||||
"""
|
||||
Update COMMONS.central.json with current per-branch commons stats.
|
||||
|
||||
This is the primary public function. Should be called after posts,
|
||||
comments, mentions, or votes to keep the central file in sync.
|
||||
|
||||
Returns:
|
||||
The data written to central file (for logging/verification)
|
||||
|
||||
Raises:
|
||||
OSError: If filesystem operations fail
|
||||
sqlite3.OperationalError: If database query fails
|
||||
"""
|
||||
branch_stats = aggregate_branch_stats()
|
||||
top_threads = query_top_threads()
|
||||
central_data = build_central_data(branch_stats, top_threads=top_threads)
|
||||
write_central_file(central_data)
|
||||
|
||||
logger.info(f"[commons] Central file updated: {len(branch_stats)} branches")
|
||||
json_handler.log_operation("update_central", {"branches_count": len(branch_stats), "success": True})
|
||||
return central_data
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user