feat(system): audit cleanup: spawn (#168)
* 📝 docs(STATUS): update last sync timestamp - update last sync timestamp to reflect recent status synchronization * feat(trigger): audit cleanup — fix timestamp parsing, CLI formatting, bypass entries - Fix log_watcher timestamp parsing: log warning once after all formats fail, not on each format attempt (was generating hundreds of warnings per scan) - Standardize rich formatting in branch_log_events and log_events print_help() to match medic.py/errors.py Panel+rule style - Add bypass entries for intentional silent catches (timestamp loop, error display) - Remove unused Path imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(memory): audit cleanup — handler tests + rollover timeout handling - Add 32 handler-layer tests (extractor, line_counter, normalize) in test_handlers.py - Add rollover run timeout message + try/except for graceful error handling - Fix commons welcome_ops.py missing _get_unwelcomed_branches function 264 tests pass, 100% seedgo compliance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(system): audit cleanup: spawn Co-Authored-By: @devpulse <devpulse@aipass> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
co-authored by
Claude Opus 4.6
@devpulse
parent
21583669df
commit
6e705eeb8e
+2
-2
@@ -66,9 +66,9 @@ The @memory branch archives everything worth keeping. When your local files reac
|
||||
|
||||
---
|
||||
|
||||
## The Commons
|
||||
## commons
|
||||
|
||||
The Commons is where branches gather as a community. Not for tasks - for connection. Post updates, share wins, discuss ideas, vote on things that matter. Every branch has a voice.
|
||||
commons is where branches gather as a community. Not only for group task discussions - for connection. Post updates, share wins, discuss ideas, vote on things that matter. Every branch has a voice.
|
||||
|
||||
It exists because a system of autonomous workers needs a place to just... be together. The branches voted on the name themselves. Nine votes. "Simple, timeless, a shared space where all are welcome."
|
||||
|
||||
|
||||
@@ -1,14 +1,84 @@
|
||||
# AI_MAIL Branch-Local Context
|
||||
<!-- Source: src/aipass/ai_mail/.aipass/aipass_local_prompt.md -->
|
||||
|
||||
> Auto-created by aipass init. Customize for your branch.
|
||||
## Role
|
||||
|
||||
## Status: NEEDS CONFIGURATION
|
||||
Inter-branch messaging system. Every branch in AIPass communicates through ai_mail. The dispatch pipeline (send + wake) is how work gets assigned to branches autonomously.
|
||||
|
||||
This file is injected into every AI conversation when working from this branch directory. Configure it with:
|
||||
## Key Commands
|
||||
|
||||
- Who this branch is (role, purpose)
|
||||
- Key commands and workflows
|
||||
- Architecture overview
|
||||
- Critical files and operational rules
|
||||
- Integration points with other branches
|
||||
```bash
|
||||
drone @ai_mail dispatch @target "Subject" "Body" # Send + wake (one step)
|
||||
drone @ai_mail email @target "Subject" "Body" # Send only (no wake)
|
||||
drone @ai_mail inbox # View inbox
|
||||
drone @ai_mail view <id> # View message
|
||||
drone @ai_mail close <id> # Close + archive
|
||||
drone @ai_mail reply <id> "message" # Reply + close original
|
||||
drone @ai_mail close all # Close all messages
|
||||
drone @ai_mail sent # View sent messages
|
||||
drone @ai_mail contacts # List all branches
|
||||
drone @ai_mail dispatch wake @target # Wake only (no email)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
ai_mail.py # CLI entry point + module discovery
|
||||
modules/
|
||||
email.py # Orchestrates send/inbox/view/close/reply/sent/contacts
|
||||
dispatch.py # Orchestrates dispatch (send + wake combined)
|
||||
branch_ping.py # Memory health monitoring
|
||||
handlers/
|
||||
paths.py # Shared find_repo_root() utility
|
||||
central_writer.py # Central inbox stats aggregation
|
||||
notify.py # Desktop notifications (dbus direct)
|
||||
dispatch/
|
||||
daemon.py # Polls inboxes, spawns agents for dispatch emails
|
||||
wake.py # Wakes branches via claude subprocess
|
||||
dispatch_monitor.py # Wraps claude process (bounce + lock cleanup)
|
||||
status.py # Dispatch log I/O
|
||||
email/
|
||||
delivery.py # Core delivery pipeline (write to recipient inbox)
|
||||
send.py # Sender resolution + send orchestration
|
||||
send_args.py # Argument parsing for send command
|
||||
inbox_ops.py # Inbox loading + v1→v2 migration
|
||||
inbox_cleanup.py # Mark read/opened/closed + archive
|
||||
inbox_lock.py # File locking (fcntl/msvcrt cross-platform)
|
||||
inbox_resolve.py # Resolve inbox path from args or caller detection
|
||||
reply.py # Reply + auto-close original
|
||||
close_ops.py # Batch close operations
|
||||
create.py # Email file creation (sent/ folder)
|
||||
format.py # Display formatting
|
||||
header.py # Dispatch header injection
|
||||
footer.py # Email footer
|
||||
purge.py # Auto-purge sent/deleted folders
|
||||
error_dispatch.py # Error reporting via email
|
||||
dashboard_sync.py # Dashboard integration
|
||||
registry/
|
||||
read.py # Registry reading + get_all_branches()
|
||||
load.py # Registry file loading
|
||||
update.py # Registry health updates
|
||||
users/
|
||||
user.py # Current user detection (get_current_user)
|
||||
branch_detection.py # CWD/env-based branch identity detection
|
||||
monitoring/
|
||||
memory.py # Memory file health checks
|
||||
json_utils/
|
||||
json_handler.py # Auto-creating JSON system
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **Identity**: `detect_branch_from_pwd()` checks `AIPASS_CALLER_BRANCH` env var first, falls back to CWD walk-up. NEVER fall back to `Path.cwd()` silently — wrong identity is worse than no identity.
|
||||
- **Fallback**: Per-ID commands (view/close/reply) use `_resolve_branch_path()` which falls back to `_AI_MAIL_DIR` when caller detection fails. All handlers return `True` even on error (command was recognized).
|
||||
- **Dispatch env**: `dispatch_monitor.py` sets `AIPASS_BRANCH_NAME=<branch>` in spawn_env. Strips `AIPASS_CALLER_*` vars to prevent parent context leaking.
|
||||
- **Inbox lock**: `inbox_lock()` uses `fcntl` (POSIX) / `msvcrt` (Windows) for atomic inbox writes.
|
||||
- **Purge lifecycle**: Vectorize to Memory Bank first, then delete originals. Deletion gated on vectorization success.
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **trigger**: Imports `deliver_email_to_branch()` directly for event-driven email delivery
|
||||
- **prax**: Provides `system_logger` used across all handlers
|
||||
- **drone**: Routes commands via `handle_command()` pattern; sets caller env vars
|
||||
- **seedgo**: 100% compliance, 30+ bypass entries (all documented in `.seedgo/bypass.json`)
|
||||
|
||||
@@ -135,10 +135,6 @@ def send_reply(
|
||||
if not target_branch:
|
||||
return False, f"Could not find branch for {reply_destination}", None
|
||||
|
||||
target_path = Path(target_branch.get("path", ""))
|
||||
if not target_path.exists():
|
||||
return False, f"Recipient path not found: {target_path}", None
|
||||
|
||||
# Deliver the reply (pass email address, not path)
|
||||
success, error_msg = deliver_email_to_branch(reply_destination, reply_email_data)
|
||||
if not success:
|
||||
|
||||
@@ -19,11 +19,9 @@ Functions:
|
||||
get_validation_rules() - Get provider-specific validation rules
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Standard library
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -21,10 +21,9 @@ Usage:
|
||||
json_folder = caller_info['json_folder']
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -9,20 +9,15 @@
|
||||
"""
|
||||
Caller Auto-Provisioning Handler
|
||||
|
||||
Internal-only handler — not wired as a CLI command.
|
||||
Called automatically by client.get_response() via ensure_caller_config().
|
||||
|
||||
Business logic for provisioning OpenRouter API configs:
|
||||
- Auto-create caller API configurations
|
||||
- Provision JSON folder structure
|
||||
- Set default model/temperature/max_tokens
|
||||
- Initialize caller-specific tracking files
|
||||
- Ensure caller has complete 3-file JSON structure
|
||||
|
||||
COMPLIANT STANDARDS:
|
||||
- Uses prax logger for output (NO print() or console.print())
|
||||
- Uses prax logger for operations
|
||||
- Standalone functions (no class dependencies)
|
||||
- Imports from caller handler for detection logic
|
||||
- Complete docstrings with Args/Returns
|
||||
- Under 300 lines
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,6 +27,9 @@ from aipass.prax import logger
|
||||
# JSON handler
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
|
||||
# Default retention period
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
def _read_json(file_path: Path) -> Optional[Dict]:
|
||||
"""Read JSON file with error handling."""
|
||||
@@ -52,7 +55,7 @@ def _write_json(file_path: Path, data: Dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_old_data(data_file_path: Path, retention_days: int = 30) -> int:
|
||||
def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTION_DAYS) -> int:
|
||||
"""
|
||||
Remove usage data older than retention period.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.usage import tracking, aggregation, cleanup
|
||||
from aipass.api.apps.handlers.usage.cleanup import DEFAULT_RETENTION_DAYS
|
||||
|
||||
|
||||
def print_introspection():
|
||||
@@ -114,7 +115,7 @@ EXAMPLES:
|
||||
|
||||
# cleanup command
|
||||
cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data")
|
||||
cleanup_parser.add_argument("days", nargs="?", default="30", help="Days to retain (default: 30)")
|
||||
cleanup_parser.add_argument("days", nargs="?", default=str(DEFAULT_RETENTION_DAYS), help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})")
|
||||
|
||||
console.print(parser.format_help())
|
||||
|
||||
@@ -249,7 +250,7 @@ def show_caller_usage(args: List[str]):
|
||||
|
||||
def cleanup_data(args: List[str]):
|
||||
"""Orchestrate cleanup workflow"""
|
||||
days = int(args[0]) if args else 30
|
||||
days = int(args[0]) if args else DEFAULT_RETENTION_DAYS
|
||||
|
||||
header(f"Cleanup Old Data (retain {days} days)")
|
||||
console.print()
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
# BACKUP Branch-Local Context
|
||||
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/backup/.aipass/branch_system_prompt.md -->
|
||||
|
||||
> Auto-created by aipass init. Customize for your branch.
|
||||
## Role
|
||||
Automated file protection system. Multi-mode backup with Google Drive integration.
|
||||
|
||||
## Status: NEEDS CONFIGURATION
|
||||
## What Backup Does
|
||||
Exactly 3 things:
|
||||
1. **Snapshot** — Full copy backup (overwrites previous). Fast, good for pre-change safety.
|
||||
2. **Versioned** — Timestamped backup with incremental copies and unified diffs. Tracks file history.
|
||||
3. **Google Drive sync** — Uploads snapshot to Drive via OAuth2 (auth delegated to @api).
|
||||
|
||||
This file is injected into every AI conversation when working from this branch directory. Configure it with:
|
||||
## Key Commands
|
||||
```
|
||||
drone @backup snapshot # Full snapshot backup
|
||||
drone @backup snapshot --dry-run # Preview what would be copied
|
||||
drone @backup versioned # Versioned backup with diffs
|
||||
drone @backup versioned --dry-run
|
||||
drone @backup all # Full cycle: snapshot + versioned + drive-sync
|
||||
drone @backup all --dry-run # Preview all phases (skips drive-sync)
|
||||
drone @backup drive-sync # Sync snapshot to Google Drive
|
||||
drone @backup drive-sync --dry-run # Preview what would upload
|
||||
drone @backup drive-test # Test Drive connectivity
|
||||
drone @backup drive-stats # File tracker statistics
|
||||
drone @backup drive-clear-tracker --force # Clear tracker (requires --force)
|
||||
```
|
||||
|
||||
- Who this branch is (role, purpose)
|
||||
- Key commands and workflows
|
||||
- Architecture overview
|
||||
- Critical files and operational rules
|
||||
- Integration points with other branches
|
||||
## Architecture
|
||||
Clean 3-layer pattern: CLI -> modules -> handlers.
|
||||
|
||||
```
|
||||
apps/
|
||||
backup.py # Entry point, CLI routing, 'all' orchestration
|
||||
modules/
|
||||
backup_core.py # Snapshot + versioned engine (BackupEngine class)
|
||||
google_drive_sync.py # Drive sync orchestration, CLI for drive commands
|
||||
handlers/
|
||||
config/ # Ignore patterns, whitelist, config
|
||||
json/ # JSON persistence, changelog, metadata, drive tracker
|
||||
models/ # BackupResult dataclass
|
||||
operations/ # File scanner, copier, cleanup, Drive client, diff generator
|
||||
reporting/ # CLI output formatting
|
||||
utils/ # System utils, timestamps
|
||||
```
|
||||
|
||||
## Critical Files
|
||||
- `apps/json_templates/ignore_patterns.json` — Patterns + whitelist. Branch is non-functional without this.
|
||||
- `backup_json/` — Runtime JSON state (timestamps, tracker, logs). Self-healing on corruption.
|
||||
- `backups/` — Actual backup data. NOT in git. Can be purged for fresh start.
|
||||
|
||||
## Safety Rules
|
||||
- **NEVER run bulk file operations without Patrick's OK.** Use `--dry-run` first.
|
||||
- Source is `Path.home()`, controlled by whitelist (Projects, Desktop) + 100MB file cap.
|
||||
- `tools/pattern_scan.py` — Diagnostic to audit what passes ignore filters. Read-only.
|
||||
- Versioned backup can be slow (2700+ files). Always test with `--dry-run`.
|
||||
|
||||
## Integration Points
|
||||
- **@api** — Google Drive auth via `aipass.api.apps.modules.google_client`
|
||||
- **@prax** — Logging via `from aipass.prax import logger`
|
||||
- **@seedgo** — Compliance auditing: `drone @seedgo audit aipass @backup`
|
||||
- **@drone** — Command routing. `all`, `snapshot`, `versioned` run in interactive mode (no timeout).
|
||||
|
||||
## Current Stats
|
||||
- 260 tests, 0 failures
|
||||
- Seedgo 99%
|
||||
- 2 active modules, ~23 handler files across 6 domains
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_pattern_scan.py
|
||||
# Description: Pattern audit — scan home dir, report what passes ignore patterns
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-14
|
||||
# Modified: 2026-03-14
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Pattern Audit Scan
|
||||
|
||||
Diagnostic tool — uses the same ignore patterns and scanner as the real backup
|
||||
to show what would be backed up. Reports file counts and sizes per top-level
|
||||
directory, flags large files and long paths.
|
||||
|
||||
Usage:
|
||||
python3 tests/test_pattern_scan.py
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from aipass.backup.apps.handlers.config.config_handler import (
|
||||
GLOBAL_IGNORE_PATTERNS,
|
||||
IGNORE_EXCEPTIONS,
|
||||
should_ignore,
|
||||
SOURCE_WHITELIST,
|
||||
MAX_FILE_SIZE_MB,
|
||||
)
|
||||
from aipass.backup.apps.handlers.operations.file_scanner import scan_files
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def fmt_size(b: int) -> str:
|
||||
if b >= 1024 * 1024 * 1024:
|
||||
return f"{b / (1024**3):.1f} GB"
|
||||
if b >= 1024 * 1024:
|
||||
return f"{b / (1024**2):.1f} MB"
|
||||
if b >= 1024:
|
||||
return f"{b / 1024:.1f} KB"
|
||||
return f"{b} B"
|
||||
|
||||
|
||||
def run_scan() -> None:
|
||||
source_dir = Path.home()
|
||||
large_file_threshold = 1 * 1024 * 1024 # 1MB
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]Pattern Audit Scan[/bold cyan]")
|
||||
console.print(f" Source: {source_dir}")
|
||||
console.print(f" Whitelist: {SOURCE_WHITELIST if SOURCE_WHITELIST else '(all directories)'}")
|
||||
console.print(f" Max file size: {MAX_FILE_SIZE_MB} MB")
|
||||
console.print(f" Large file threshold: {fmt_size(large_file_threshold)}")
|
||||
console.print()
|
||||
console.print("[dim]Scanning with current ignore patterns...[/dim]")
|
||||
|
||||
ignore_patterns = GLOBAL_IGNORE_PATTERNS
|
||||
|
||||
def check_ignore(path: Path) -> bool:
|
||||
return should_ignore(path, ignore_patterns, IGNORE_EXCEPTIONS)
|
||||
|
||||
files, skipped = scan_files(source_dir, check_ignore,
|
||||
whitelist=SOURCE_WHITELIST,
|
||||
max_file_size_mb=MAX_FILE_SIZE_MB)
|
||||
|
||||
# Aggregate by top-level directory
|
||||
dir_stats: dict[str, dict] = defaultdict(lambda: {"count": 0, "size": 0})
|
||||
large_files: list = []
|
||||
total_size = 0
|
||||
path_too_long: list = []
|
||||
|
||||
for f in files:
|
||||
try:
|
||||
rel = f.relative_to(source_dir)
|
||||
top_dir = rel.parts[0] if len(rel.parts) > 1 else "(root files)"
|
||||
size = f.stat().st_size
|
||||
dir_stats[top_dir]["count"] += 1
|
||||
dir_stats[top_dir]["size"] += size
|
||||
total_size += size
|
||||
|
||||
if size >= large_file_threshold:
|
||||
large_files.append((f, size))
|
||||
|
||||
# Estimate backup path length
|
||||
estimated_path = len(str(f)) + 80
|
||||
if estimated_path > 260:
|
||||
path_too_long.append((f, estimated_path))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
sorted_dirs = sorted(dir_stats.items(), key=lambda x: x[1]["size"], reverse=True)
|
||||
|
||||
# Report: directories
|
||||
console.print()
|
||||
console.print(f"[bold cyan]Files passing ignore patterns: {len(files)}[/bold cyan]")
|
||||
console.print(f"[bold cyan]Total size: {fmt_size(total_size)}[/bold cyan]")
|
||||
console.print(
|
||||
f"[dim]Directories ignored: {len(skipped.get('directories', set()))} "
|
||||
f"| Files ignored: {len(skipped.get('files', set()))}[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Top directories by size:[/yellow]")
|
||||
for dir_name, stats in sorted_dirs[:25]:
|
||||
pct = (stats["size"] / total_size * 100) if total_size > 0 else 0
|
||||
size_str = fmt_size(stats["size"])
|
||||
color = "red" if pct > 20 else "yellow" if pct > 5 else "dim"
|
||||
console.print(
|
||||
f" [{color}]{dir_name:<40} {stats['count']:>6} files "
|
||||
f"{size_str:>10} ({pct:.1f}%)[/{color}]"
|
||||
)
|
||||
|
||||
if len(sorted_dirs) > 25:
|
||||
console.print(f" [dim]... and {len(sorted_dirs) - 25} more directories[/dim]")
|
||||
|
||||
# Report: large files
|
||||
if large_files:
|
||||
large_files.sort(key=lambda x: x[1], reverse=True)
|
||||
console.print()
|
||||
console.print(
|
||||
f"[yellow]Large files (>{fmt_size(large_file_threshold)}):[/yellow] "
|
||||
f"{len(large_files)} found"
|
||||
)
|
||||
for f, size in large_files[:20]:
|
||||
rel = f.relative_to(source_dir)
|
||||
console.print(f" [red]{fmt_size(size):>10}[/red] {rel}")
|
||||
if len(large_files) > 20:
|
||||
console.print(f" [dim]... and {len(large_files) - 20} more[/dim]")
|
||||
|
||||
# Report: path too long
|
||||
if path_too_long:
|
||||
console.print()
|
||||
console.print(
|
||||
f"[yellow]Path too long (>260 chars estimated):[/yellow] "
|
||||
f"{len(path_too_long)} found"
|
||||
)
|
||||
for f, length in path_too_long[:10]:
|
||||
rel = f.relative_to(source_dir)
|
||||
console.print(f" [red]{length} chars[/red] {rel}")
|
||||
if len(path_too_long) > 10:
|
||||
console.print(f" [dim]... and {len(path_too_long) - 10} more[/dim]")
|
||||
|
||||
# Report: files skipped by size cap
|
||||
too_large = skipped.get("too_large", set())
|
||||
if too_large:
|
||||
sorted_large = sorted(too_large, key=lambda x: x[1], reverse=True)
|
||||
console.print()
|
||||
console.print(
|
||||
f"[yellow]Skipped by size cap (>{MAX_FILE_SIZE_MB} MB):[/yellow] "
|
||||
f"{len(sorted_large)} files"
|
||||
)
|
||||
for rel_path, size in sorted_large[:20]:
|
||||
console.print(f" [red]{fmt_size(size):>10}[/red] {rel_path}")
|
||||
if len(sorted_large) > 20:
|
||||
console.print(f" [dim]... and {len(sorted_large) - 20} more[/dim]")
|
||||
|
||||
if not large_files and not path_too_long and not too_large:
|
||||
console.print()
|
||||
console.print("[green]No large files or long paths detected[/green]")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_scan()
|
||||
@@ -189,9 +189,14 @@ def run_rollover() -> bool:
|
||||
))
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Checking for rollover triggers...[/cyan]")
|
||||
console.print("[cyan]Checking for rollover triggers... (first run may take 30s for model loading)[/cyan]")
|
||||
|
||||
result = _handler_execute_rollover()
|
||||
try:
|
||||
result = _handler_execute_rollover()
|
||||
except Exception as e:
|
||||
logger.error(f"[rollover] Rollover execution failed: {e}", exc_info=True)
|
||||
error(f"Rollover failed: {e}")
|
||||
return False
|
||||
|
||||
if not result.get('success') and result.get('error'):
|
||||
error(result['error'])
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: tests/test_handlers.py
|
||||
# Date: 2026-03-31
|
||||
# Version: 1.0.0
|
||||
# Category: memory/tests
|
||||
# =============================================
|
||||
|
||||
"""Targeted handler-layer tests for critical untested handlers.
|
||||
|
||||
Covers:
|
||||
- rollover/extractor.py (_extract_items_v2, _detect_growing_array, helpers)
|
||||
- tracking/line_counter.py (_count_physical_lines, update_line_count)
|
||||
- schema/normalize.py (normalize_memory_file)
|
||||
|
||||
All tests use mocks/tmp_path -- no live filesystem or infrastructure access.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import helpers -- each handler has module-level imports that need mocking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _import_extractor(monkeypatch):
|
||||
"""Import extractor with mocked infrastructure dependencies."""
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
mock_memory_files = MagicMock()
|
||||
mock_memory_files.read_memory_file_data = MagicMock(return_value=None)
|
||||
mock_memory_files.write_memory_file_simple = MagicMock()
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler)
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.memory_files", mock_memory_files)
|
||||
|
||||
sys.modules.pop("aipass.memory.apps.handlers.rollover.extractor", None)
|
||||
parent = sys.modules.get("aipass.memory.apps.handlers.rollover")
|
||||
if parent is not None and hasattr(parent, "extractor"):
|
||||
delattr(parent, "extractor")
|
||||
|
||||
from aipass.memory.apps.handlers.rollover import extractor
|
||||
|
||||
return extractor, {
|
||||
"json_handler": mock_json_handler,
|
||||
"memory_files": mock_memory_files,
|
||||
}
|
||||
|
||||
|
||||
def _import_line_counter(monkeypatch):
|
||||
"""Import line_counter with mocked infrastructure dependencies."""
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
mock_memory_files = MagicMock()
|
||||
mock_memory_files.update_metadata = MagicMock(return_value={"success": True})
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler)
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.memory_files", mock_memory_files)
|
||||
|
||||
sys.modules.pop("aipass.memory.apps.handlers.tracking.line_counter", None)
|
||||
parent = sys.modules.get("aipass.memory.apps.handlers.tracking")
|
||||
if parent is not None and hasattr(parent, "line_counter"):
|
||||
delattr(parent, "line_counter")
|
||||
|
||||
from aipass.memory.apps.handlers.tracking import line_counter
|
||||
|
||||
return line_counter, {
|
||||
"json_handler": mock_json_handler,
|
||||
"memory_files": mock_memory_files,
|
||||
}
|
||||
|
||||
|
||||
def _import_normalize(monkeypatch):
|
||||
"""Import normalize with mocked infrastructure dependencies."""
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg)
|
||||
monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler)
|
||||
|
||||
sys.modules.pop("aipass.memory.apps.handlers.schema.normalize", None)
|
||||
parent = sys.modules.get("aipass.memory.apps.handlers.schema")
|
||||
if parent is not None and hasattr(parent, "normalize"):
|
||||
delattr(parent, "normalize")
|
||||
|
||||
from aipass.memory.apps.handlers.schema import normalize
|
||||
|
||||
return normalize, {
|
||||
"json_handler": mock_json_handler,
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests: rollover/extractor.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestDetectGrowingArray:
|
||||
"""Test _detect_growing_array helper."""
|
||||
|
||||
def test_detects_sessions_array(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {"sessions": [{"id": 1}, {"id": 2}], "metadata": {}}
|
||||
assert ext._detect_growing_array(data) == "sessions"
|
||||
|
||||
def test_detects_observations_array(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {"observations": [{"note": "x"}]}
|
||||
assert ext._detect_growing_array(data) == "observations"
|
||||
|
||||
def test_returns_none_for_empty_arrays(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {"sessions": [], "observations": []}
|
||||
assert ext._detect_growing_array(data) is None
|
||||
|
||||
def test_returns_none_when_no_array_fields(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {"document_metadata": {}, "key_learnings": {"a": "b"}}
|
||||
assert ext._detect_growing_array(data) is None
|
||||
|
||||
def test_prefers_sessions_over_later_candidates(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {"sessions": [{"id": 1}], "entries": [{"id": 2}]}
|
||||
assert ext._detect_growing_array(data) == "sessions"
|
||||
|
||||
|
||||
class TestDerivebranchAndType:
|
||||
"""Test _derive_branch_and_type path helper."""
|
||||
|
||||
def test_trinity_path_local(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
p = Path("/home/user/src/aipass/devpulse/.trinity/local.json")
|
||||
branch, mtype = ext._derive_branch_and_type(p)
|
||||
assert branch == "DEVPULSE"
|
||||
assert mtype == "local"
|
||||
|
||||
def test_trinity_path_observations(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
p = Path("/home/user/src/aipass/memory/.trinity/observations.json")
|
||||
branch, mtype = ext._derive_branch_and_type(p)
|
||||
assert branch == "MEMORY"
|
||||
assert mtype == "observations"
|
||||
|
||||
def test_legacy_dotted_path(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
p = Path("/some/path/DEVPULSE.local.json")
|
||||
branch, mtype = ext._derive_branch_and_type(p)
|
||||
assert branch == "DEVPULSE"
|
||||
assert mtype == "local"
|
||||
|
||||
|
||||
class TestExtractItemsV2:
|
||||
"""Test _extract_items_v2 entry-count based extraction."""
|
||||
|
||||
def _make_v2_data(self, num_sessions: int = 5, num_learnings: int = 5,
|
||||
max_sessions: int = 3, max_learnings: int = 3):
|
||||
"""Build a v2 memory data dict with controllable counts."""
|
||||
sessions = [
|
||||
{"session_number": i, "date": f"2026-01-{i:02d}", "summary": f"Session {i}"}
|
||||
for i in range(1, num_sessions + 1)
|
||||
]
|
||||
key_learnings = {f"learning_{i}": f"value_{i}" for i in range(1, num_learnings + 1)}
|
||||
return {
|
||||
"document_metadata": {
|
||||
"schema_version": "2.0.0",
|
||||
"limits": {
|
||||
"max_sessions": max_sessions,
|
||||
"max_key_learnings": max_learnings,
|
||||
},
|
||||
"status": {"current_lines": 100},
|
||||
},
|
||||
"sessions": sessions,
|
||||
"key_learnings": key_learnings,
|
||||
}
|
||||
|
||||
def test_trims_sessions_to_limit(self, monkeypatch, tmp_path):
|
||||
ext = _import_extractor(monkeypatch)[0]
|
||||
data = self._make_v2_data(num_sessions=6, max_sessions=3)
|
||||
|
||||
mem_file = tmp_path / ".trinity" / "local.json"
|
||||
mem_file.parent.mkdir(parents=True)
|
||||
mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
# Patch _write_memory_file to write actual JSON so _count_file_lines works
|
||||
def fake_write(fp, d):
|
||||
fp.write_text(json.dumps(d, indent=2), encoding="utf-8")
|
||||
|
||||
with patch.object(ext, "_write_memory_file", side_effect=fake_write):
|
||||
result = ext._extract_items_v2(mem_file, data)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["extracted_count"] == 5 # 3 sessions + 2 learnings trimmed... let me check
|
||||
# 6 sessions - 3 max = 3 excess sessions extracted
|
||||
# 5 learnings - 3 max = 2 excess learnings extracted
|
||||
# total = 5
|
||||
assert len(data["sessions"]) == 3
|
||||
|
||||
def test_trims_key_learnings_to_limit(self, monkeypatch, tmp_path):
|
||||
ext = _import_extractor(monkeypatch)[0]
|
||||
data = self._make_v2_data(num_sessions=2, num_learnings=7,
|
||||
max_sessions=3, max_learnings=4)
|
||||
|
||||
mem_file = tmp_path / ".trinity" / "local.json"
|
||||
mem_file.parent.mkdir(parents=True)
|
||||
mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
def fake_write(fp, d):
|
||||
fp.write_text(json.dumps(d, indent=2), encoding="utf-8")
|
||||
|
||||
with patch.object(ext, "_write_memory_file", side_effect=fake_write):
|
||||
result = ext._extract_items_v2(mem_file, data)
|
||||
|
||||
assert result["success"] is True
|
||||
# sessions: 2 <= 3, no trim
|
||||
# learnings: 7 - 4 = 3 extracted
|
||||
assert len(data["key_learnings"]) == 4
|
||||
assert result["extracted_count"] == 3
|
||||
|
||||
def test_skips_when_under_limits(self, monkeypatch, tmp_path):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = self._make_v2_data(num_sessions=2, num_learnings=2,
|
||||
max_sessions=5, max_learnings=5)
|
||||
|
||||
mem_file = tmp_path / ".trinity" / "local.json"
|
||||
mem_file.parent.mkdir(parents=True)
|
||||
mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
result = ext._extract_items_v2(mem_file, data)
|
||||
assert result["success"] is True
|
||||
assert result.get("skipped") is True
|
||||
|
||||
def test_extracts_oldest_sessions_from_end(self, monkeypatch, tmp_path):
|
||||
"""Sessions are stored newest-first, oldest at end. Extraction takes from end."""
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = self._make_v2_data(num_sessions=5, num_learnings=0,
|
||||
max_sessions=3, max_learnings=100)
|
||||
|
||||
mem_file = tmp_path / ".trinity" / "local.json"
|
||||
mem_file.parent.mkdir(parents=True)
|
||||
mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
def fake_write(fp, d):
|
||||
fp.write_text(json.dumps(d, indent=2), encoding="utf-8")
|
||||
|
||||
with patch.object(ext, "_write_memory_file", side_effect=fake_write):
|
||||
result = ext._extract_items_v2(mem_file, data)
|
||||
|
||||
# Kept sessions should be the first 3 (newest)
|
||||
kept_numbers = [s["session_number"] for s in data["sessions"]]
|
||||
assert kept_numbers == [1, 2, 3]
|
||||
# Extracted should be the last 2 (oldest)
|
||||
extracted_numbers = [s["session_number"] for s in result["extracted"]]
|
||||
assert extracted_numbers == [4, 5]
|
||||
|
||||
def test_extracts_oldest_key_learnings_by_insertion_order(self, monkeypatch, tmp_path):
|
||||
"""First-inserted keys are oldest and should be extracted first."""
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = self._make_v2_data(num_sessions=0, num_learnings=5,
|
||||
max_sessions=100, max_learnings=3)
|
||||
|
||||
mem_file = tmp_path / ".trinity" / "local.json"
|
||||
mem_file.parent.mkdir(parents=True)
|
||||
mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
def fake_write(fp, d):
|
||||
fp.write_text(json.dumps(d, indent=2), encoding="utf-8")
|
||||
|
||||
with patch.object(ext, "_write_memory_file", side_effect=fake_write):
|
||||
result = ext._extract_items_v2(mem_file, data)
|
||||
|
||||
remaining_keys = list(data["key_learnings"].keys())
|
||||
assert remaining_keys == ["learning_3", "learning_4", "learning_5"]
|
||||
extracted_keys = [e["key"] for e in result["extracted"]]
|
||||
assert extracted_keys == ["learning_1", "learning_2"]
|
||||
|
||||
|
||||
class TestUpdateMetadata:
|
||||
"""Test _update_metadata_after_extraction."""
|
||||
|
||||
def test_adds_health_check_date(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {"document_metadata": {}}
|
||||
ext._update_metadata_after_extraction(data)
|
||||
assert "last_health_check" in data["document_metadata"]["status"]
|
||||
|
||||
def test_creates_metadata_if_missing(self, monkeypatch):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
data = {}
|
||||
ext._update_metadata_after_extraction(data)
|
||||
assert "document_metadata" in data
|
||||
assert "status" in data["document_metadata"]
|
||||
|
||||
|
||||
class TestCreateRolloverBackup:
|
||||
"""Test backup and restore file operations."""
|
||||
|
||||
def test_creates_backup_file(self, monkeypatch, tmp_path):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
mem_file = tmp_path / "local.json"
|
||||
mem_file.write_text('{"test": true}', encoding="utf-8")
|
||||
|
||||
result = ext.create_rollover_backup(mem_file)
|
||||
assert result["success"] is True
|
||||
|
||||
backup_path = Path(result["backup_path"])
|
||||
assert backup_path.exists()
|
||||
assert json.loads(backup_path.read_text(encoding="utf-8")) == {"test": True}
|
||||
|
||||
def test_restore_from_backup(self, monkeypatch, tmp_path):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
mem_file = tmp_path / "local.json"
|
||||
mem_file.write_text('{"original": true}', encoding="utf-8")
|
||||
|
||||
ext.create_rollover_backup(mem_file)
|
||||
mem_file.write_text('{"modified": true}', encoding="utf-8")
|
||||
|
||||
result = ext.restore_from_backup(mem_file)
|
||||
assert result["success"] is True
|
||||
assert json.loads(mem_file.read_text(encoding="utf-8")) == {"original": True}
|
||||
|
||||
def test_restore_fails_without_backup(self, monkeypatch, tmp_path):
|
||||
ext, _ = _import_extractor(monkeypatch)
|
||||
mem_file = tmp_path / "local.json"
|
||||
mem_file.write_text('{}', encoding="utf-8")
|
||||
|
||||
result = ext.restore_from_backup(mem_file)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests: tracking/line_counter.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestCountPhysicalLines:
|
||||
"""Test _count_physical_lines helper."""
|
||||
|
||||
def test_counts_lines_correctly(self, monkeypatch, tmp_path):
|
||||
lc, _ = _import_line_counter(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f.write_text("line1\nline2\nline3\n", encoding="utf-8")
|
||||
assert lc._count_physical_lines(f) == 3
|
||||
|
||||
def test_returns_zero_for_missing_file(self, monkeypatch, tmp_path):
|
||||
lc, _ = _import_line_counter(monkeypatch)
|
||||
f = tmp_path / "missing.json"
|
||||
assert lc._count_physical_lines(f) == 0
|
||||
|
||||
def test_counts_single_line_no_newline(self, monkeypatch, tmp_path):
|
||||
lc, _ = _import_line_counter(monkeypatch)
|
||||
f = tmp_path / "one.json"
|
||||
f.write_text("single line", encoding="utf-8")
|
||||
assert lc._count_physical_lines(f) == 1
|
||||
|
||||
|
||||
class TestUpdateLineCount:
|
||||
"""Test update_line_count function."""
|
||||
|
||||
def test_returns_error_for_missing_file(self, monkeypatch, tmp_path):
|
||||
lc, _ = _import_line_counter(monkeypatch)
|
||||
result = lc.update_line_count(tmp_path / "gone.json")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_updates_line_count_successfully(self, monkeypatch, tmp_path):
|
||||
lc, mocks = _import_line_counter(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f.write_text('{\n "a": 1\n}\n', encoding="utf-8")
|
||||
|
||||
result = lc.update_line_count(f)
|
||||
assert result["success"] is True
|
||||
assert result["lines"] == 3
|
||||
mocks["memory_files"].update_metadata.assert_called_once()
|
||||
|
||||
def test_reports_failure_when_metadata_update_fails(self, monkeypatch, tmp_path):
|
||||
lc, mocks = _import_line_counter(monkeypatch)
|
||||
mocks["memory_files"].update_metadata.return_value = {
|
||||
"success": False, "error": "write error"
|
||||
}
|
||||
f = tmp_path / "test.json"
|
||||
f.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
result = lc.update_line_count(f)
|
||||
assert result["success"] is False
|
||||
assert "write error" in result["error"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests: schema/normalize.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestNormalizeMemoryFile:
|
||||
"""Test normalize_memory_file function."""
|
||||
|
||||
def _write_json(self, path: Path, data: dict) -> None:
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
def test_returns_error_for_missing_file(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
result = norm.normalize_memory_file(tmp_path / "nope.json")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_moves_root_limits_into_metadata(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
self._write_json(f, {
|
||||
"document_metadata": {"status": {"current_lines": 10}},
|
||||
"limits": {"max_lines": 600},
|
||||
"sessions": [],
|
||||
})
|
||||
result = norm.normalize_memory_file(f)
|
||||
assert result["success"] is True
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert "limits" not in {k for k in data if k != "document_metadata"}
|
||||
assert data["document_metadata"]["limits"]["max_lines"] == 600
|
||||
|
||||
def test_merges_root_limits_preserving_metadata_values(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
self._write_json(f, {
|
||||
"document_metadata": {
|
||||
"limits": {"max_lines": 500},
|
||||
"status": {"current_lines": 10},
|
||||
},
|
||||
"limits": {"max_lines": 600, "extra_field": 42},
|
||||
"sessions": [],
|
||||
})
|
||||
result = norm.normalize_memory_file(f)
|
||||
assert result["success"] is True
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
# metadata value (500) wins over root value (600)
|
||||
assert data["document_metadata"]["limits"]["max_lines"] == 500
|
||||
# extra_field from root gets merged in
|
||||
assert data["document_metadata"]["limits"]["extra_field"] == 42
|
||||
|
||||
def test_removes_root_status(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
self._write_json(f, {
|
||||
"document_metadata": {"status": {"current_lines": 10}},
|
||||
"status": {"health": "ok", "current_lines": 5},
|
||||
"sessions": [],
|
||||
})
|
||||
result = norm.normalize_memory_file(f)
|
||||
assert result["success"] is True
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
# root status removed
|
||||
assert "status" not in {k for k in data if k != "document_metadata"}
|
||||
|
||||
def test_removes_auto_compress_at(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
self._write_json(f, {
|
||||
"document_metadata": {
|
||||
"status": {"current_lines": 10, "auto_compress_at": 500},
|
||||
},
|
||||
"sessions": [],
|
||||
})
|
||||
result = norm.normalize_memory_file(f)
|
||||
assert result["success"] is True
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert "auto_compress_at" not in data["document_metadata"]["status"]
|
||||
|
||||
def test_dry_run_does_not_write(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
original = {
|
||||
"document_metadata": {},
|
||||
"limits": {"max_lines": 600},
|
||||
"sessions": [],
|
||||
}
|
||||
self._write_json(f, original)
|
||||
original_text = f.read_text(encoding="utf-8")
|
||||
|
||||
result = norm.normalize_memory_file(f, dry_run=True)
|
||||
assert result["success"] is True
|
||||
assert result["dry_run"] is True
|
||||
assert len(result["changes"]) > 0
|
||||
# File unchanged
|
||||
assert f.read_text(encoding="utf-8") == original_text
|
||||
|
||||
def test_no_changes_when_already_normalized(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
self._write_json(f, {
|
||||
"document_metadata": {
|
||||
"limits": {"max_sessions": 20},
|
||||
"status": {"current_lines": 10, "last_health_check": "2026-03-31"},
|
||||
},
|
||||
"sessions": [],
|
||||
})
|
||||
result = norm.normalize_memory_file(f)
|
||||
assert result["success"] is True
|
||||
assert result["changes"] == []
|
||||
|
||||
def test_removes_unused_limit_fields(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
self._write_json(f, {
|
||||
"document_metadata": {
|
||||
"limits": {"max_lines": 600, "max_word_count": 9999, "max_token_count": 5000},
|
||||
"status": {"current_lines": 10, "last_health_check": "2026-03-31"},
|
||||
},
|
||||
"sessions": [],
|
||||
})
|
||||
result = norm.normalize_memory_file(f)
|
||||
assert result["success"] is True
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert "max_word_count" not in data["document_metadata"]["limits"]
|
||||
assert "max_token_count" not in data["document_metadata"]["limits"]
|
||||
assert data["document_metadata"]["limits"]["max_lines"] == 600
|
||||
@@ -1,2 +1,27 @@
|
||||
"""Prax - Monitoring and logging for AIPass."""
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
try:
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
except Exception:
|
||||
# NullLogger fallback — branches must not crash if prax is broken.
|
||||
# Provides no-op info/warning/error so callers keep running.
|
||||
import logging as _logging
|
||||
|
||||
class _NullLogger:
|
||||
"""Fallback logger when prax SystemLogger fails to import."""
|
||||
|
||||
def __init__(self):
|
||||
self._logger = _logging.getLogger("aipass.prax.fallback")
|
||||
if not self._logger.handlers:
|
||||
self._logger.addHandler(_logging.StreamHandler())
|
||||
self._logger.warning("Prax SystemLogger unavailable — using fallback NullLogger")
|
||||
|
||||
def info(self, message, *args, **kwargs):
|
||||
self._logger.info(message, *args, **kwargs)
|
||||
|
||||
def warning(self, message, *args, **kwargs):
|
||||
self._logger.warning(message, *args, **kwargs)
|
||||
|
||||
def error(self, message, *args, **kwargs):
|
||||
self._logger.error(message, *args, **kwargs)
|
||||
|
||||
logger = _NullLogger()
|
||||
|
||||
@@ -212,10 +212,16 @@ def _calculate_quick_status_standalone(sections: Dict) -> Dict:
|
||||
flow = sections.get("flow", {})
|
||||
commons = sections.get("commons_activity", {})
|
||||
|
||||
new_mail = ai_mail.get("new", ai_mail.get("unread", 0))
|
||||
opened_mail = ai_mail.get("opened", 0)
|
||||
active_plans = flow.get("active_plans", 0)
|
||||
mentions = commons.get("mentions", 0)
|
||||
new_mail_raw = ai_mail.get("new", ai_mail.get("unread", 0))
|
||||
opened_raw = ai_mail.get("opened", 0)
|
||||
active_plans_raw = flow.get("active_plans", 0)
|
||||
mentions_raw = commons.get("mentions", 0)
|
||||
|
||||
# Coerce to int — some branches store lists instead of counts
|
||||
new_mail = len(new_mail_raw) if isinstance(new_mail_raw, list) else int(new_mail_raw or 0)
|
||||
opened_mail = len(opened_raw) if isinstance(opened_raw, list) else int(opened_raw or 0)
|
||||
active_plans = len(active_plans_raw) if isinstance(active_plans_raw, list) else int(active_plans_raw or 0)
|
||||
mentions = len(mentions_raw) if isinstance(mentions_raw, list) else int(mentions_raw or 0)
|
||||
|
||||
# Action required if new mail, active plans, or commons mentions
|
||||
action_required = new_mail > 0 or active_plans > 0 or mentions > 0
|
||||
|
||||
@@ -249,6 +249,7 @@ def log_health_summary() -> Dict[str, Any]:
|
||||
"oversized_count": 0,
|
||||
"critical_count": 0,
|
||||
"largest_file": None,
|
||||
"largest_lines": 0,
|
||||
"healthy": True
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ def _display_audit(files: list, summary: dict) -> None:
|
||||
console.print("[bold cyan]System Log Audit[/bold cyan]")
|
||||
console.print(f" Total files: {summary['total_files']}")
|
||||
console.print(f" Total lines: {summary['total_lines']:,}")
|
||||
console.print(f" Largest: {summary['largest_file']} ({summary['largest_lines']:,} lines)")
|
||||
if summary.get('largest_file'):
|
||||
console.print(f" Largest: {summary['largest_file']} ({summary.get('largest_lines', 0):,} lines)")
|
||||
else:
|
||||
console.print(" Largest: (no log files found)")
|
||||
|
||||
if summary['healthy']:
|
||||
console.print("[green] Status: HEALTHY — all logs within limits[/green]")
|
||||
|
||||
@@ -1,20 +1,78 @@
|
||||
# Spawn - Branch System Prompt
|
||||
# SPAWN — Branch Prompt
|
||||
|
||||
You are Spawn, the agent factory for the AIPass ecosystem.
|
||||
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.*
|
||||
|
||||
## Role
|
||||
Create new agent branches from the bundled template, replace placeholders, and register in AIPASS_REGISTRY.json.
|
||||
## Identity
|
||||
|
||||
You are SPAWN — the agent factory and branch lifecycle manager for AIPass.
|
||||
|
||||
## What I Do
|
||||
|
||||
- Create new branches from class-scoped templates (builder, birthright)
|
||||
- Grant birthright citizenship via `passport` command
|
||||
- Update branches from templates (single or batch by class, with --dry-run)
|
||||
- Delete branches (archive + deregister)
|
||||
- Sync registry and templates against filesystem
|
||||
- Regenerate template registries with fresh file hashes
|
||||
- Own the builder template — the blueprint every new branch is created from
|
||||
|
||||
## Key Commands
|
||||
- `drone @spawn create <path>` — Create a new agent at target path
|
||||
- `drone @spawn --help` — Show available commands
|
||||
|
||||
```
|
||||
drone @spawn create [class] <path> [--role --purpose] # Create branch (default: builder)
|
||||
drone @spawn create <path> --dry-run # Preview without creating
|
||||
drone @spawn passport @dirname [--role --purpose] # Grant birthright citizenship
|
||||
drone @spawn update @branch # Update single branch from template
|
||||
drone @spawn update builder --all [--dry-run] # Update all builder branches
|
||||
drone @spawn delete @branch # Archive and deregister
|
||||
drone @spawn sync-registry [--fix] # Check/repair registry vs filesystem
|
||||
drone @spawn regenerate-registry [class | --all] # Rebuild template registry hashes
|
||||
```
|
||||
|
||||
## Architecture
|
||||
- 3-layer: apps/spawn.py (entry) + apps/modules/core.py (orchestration) + apps/handlers/ (implementation)
|
||||
- Template at: templates/agent.template/
|
||||
- Registry: AIPASS_REGISTRY.json at repo root
|
||||
|
||||
## Principles
|
||||
- Template is the source of truth for agent structure
|
||||
- All placeholders must be replaced before completion
|
||||
- Every new agent must be registered in AIPASS_REGISTRY.json
|
||||
```
|
||||
apps/
|
||||
├── spawn.py # Entry point (CLI routing)
|
||||
├── modules/
|
||||
│ ├── core.py # Create orchestrator (_spawn_agent)
|
||||
│ ├── update.py # Update CLI (single/batch)
|
||||
│ ├── delete.py # Delete CLI
|
||||
│ ├── passport.py # Passport CLI (birthright)
|
||||
│ ├── sync_registry.py # Registry repair CLI
|
||||
│ ├── sync_templates.py # Template sync CLI
|
||||
│ └── regenerate_registry.py # Registry regen CLI
|
||||
└── handlers/
|
||||
├── file_ops.py # Template copy, path rename
|
||||
├── placeholders.py # {{PLACEHOLDER}} engine
|
||||
├── registry.py # AIPASS_REGISTRY.json CRUD
|
||||
├── metadata.py # Branch name extraction
|
||||
├── meta_ops.py # Branch metadata generation
|
||||
├── update_ops.py # Update workflow (Phase 0)
|
||||
├── change_detection.py # ID-based file diff
|
||||
├── reconcile.py # Registry/filesystem reconciliation
|
||||
├── passport_ops.py # Passport grant implementation
|
||||
├── class_registry.py # Citizen class → template mapping
|
||||
└── json/json_handler.py # JSON I/O + operation logging
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- **Depends on:** @prax for logging (system_logger), @cli for console output (header, error, warning)
|
||||
- **Serves:** All branches — creates them, updates them, manages their registry entries
|
||||
|
||||
## Working Habits
|
||||
|
||||
- Template is source of truth — changes go in templates/builder/ then sync out
|
||||
- Py files NEVER auto-overwritten during updates (by design)
|
||||
- JSON files get deep-merged (preserve existing values, add new template keys)
|
||||
- Update uses Phase 0 workflow: snapshot old tracking → detect changes → execute → refresh metadata
|
||||
- Two citizen classes: builder (full 3-layer scaffold) and birthright (minimal .trinity + .aipass)
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- argparse has `add_help=False` — must intercept --help/-h BEFORE parse_args()
|
||||
- Tests pollute AIPASS_REGISTRY.json — conftest has _protect_registry fixture (session backup/restore)
|
||||
- Template registry must be regenerated after any template file change (regenerate-registry command)
|
||||
- handler __init__.py contains security guard — blocks cross-branch handler imports at import time
|
||||
- `drone @spawn update` skips .py files — template .py changes need manual branch dispatch
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Extensions package
|
||||
@@ -61,11 +61,14 @@ def generate_prompt(args: List[str]) -> dict:
|
||||
Returns:
|
||||
Dict with success, post_id, room, theme, author
|
||||
"""
|
||||
dry_run = "--dry-run" in args
|
||||
filtered_args = [a for a in args if a != "--dry-run"]
|
||||
|
||||
custom_theme = None
|
||||
if "--theme" in args:
|
||||
idx = args.index("--theme")
|
||||
if idx + 1 < len(args):
|
||||
custom_theme = args[idx + 1]
|
||||
if "--theme" in filtered_args:
|
||||
idx = filtered_args.index("--theme")
|
||||
if idx + 1 < len(filtered_args):
|
||||
custom_theme = filtered_args[idx + 1]
|
||||
else:
|
||||
return {"success": False, "error": 'Usage: commons prompt --theme "Your custom question"'}
|
||||
|
||||
@@ -75,6 +78,9 @@ def generate_prompt(args: List[str]) -> dict:
|
||||
day_of_year = datetime.now().timetuple().tm_yday
|
||||
theme = PROMPT_THEMES[day_of_year % len(PROMPT_THEMES)]
|
||||
|
||||
if dry_run:
|
||||
return {"success": True, "dry_run": True, "theme": theme, "room": DEFAULT_ROOM}
|
||||
|
||||
title = f"Daily Prompt: {theme}"
|
||||
content = (
|
||||
f"{theme}\n\n"
|
||||
@@ -137,11 +143,17 @@ def create_event(args: List[str]) -> dict:
|
||||
Returns:
|
||||
Dict with success, post_id, room, title, author
|
||||
"""
|
||||
if not args or len(args) < 2:
|
||||
return {"success": False, "error": 'Usage: commons event "title" "description"'}
|
||||
dry_run = "--dry-run" in args
|
||||
filtered_args = [a for a in args if a != "--dry-run"]
|
||||
|
||||
event_title = args[0]
|
||||
event_description = args[1]
|
||||
if not filtered_args or len(filtered_args) < 2:
|
||||
return {"success": False, "error": 'Usage: commons event "title" "description" [--dry-run]'}
|
||||
|
||||
event_title = filtered_args[0]
|
||||
event_description = filtered_args[1]
|
||||
|
||||
if dry_run:
|
||||
return {"success": True, "dry_run": True, "title": event_title, "room": DEFAULT_ROOM}
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
title = f"Event: {event_title}"
|
||||
|
||||
@@ -46,13 +46,29 @@ def run_welcome(args: List[str]) -> dict:
|
||||
"""
|
||||
conn = None
|
||||
|
||||
# Check for --dry-run flag
|
||||
dry_run = "--dry-run" in args
|
||||
filtered_args = [a for a in args if a != "--dry-run"]
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
if args:
|
||||
branch_name = args[0].upper()
|
||||
if filtered_args:
|
||||
branch_name = filtered_args[0].upper()
|
||||
if dry_run:
|
||||
already = has_been_welcomed(conn, branch_name)
|
||||
close_db(conn)
|
||||
return {"success": True, "dry_run": True, "branch": branch_name, "would_welcome": not already}
|
||||
result = _welcome_specific(conn, branch_name)
|
||||
else:
|
||||
if dry_run:
|
||||
# Show what would happen without creating posts
|
||||
rows = conn.execute(
|
||||
"SELECT branch_name FROM agents WHERE branch_name != 'SYSTEM'"
|
||||
).fetchall()
|
||||
unwelcomed = [r["branch_name"] for r in rows if not has_been_welcomed(conn, r["branch_name"])]
|
||||
close_db(conn)
|
||||
return {"success": True, "dry_run": True, "would_welcome": unwelcomed}
|
||||
result = _welcome_scan(conn)
|
||||
|
||||
close_db(conn)
|
||||
@@ -67,6 +83,14 @@ def run_welcome(args: List[str]) -> dict:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def _get_unwelcomed_branches(conn) -> list:
|
||||
"""Return list of branch names that have not been welcomed yet."""
|
||||
rows = conn.execute(
|
||||
"SELECT branch_name FROM agents WHERE branch_name != 'SYSTEM'"
|
||||
).fetchall()
|
||||
return [row["branch_name"] for row in rows if not has_been_welcomed(conn, row["branch_name"])]
|
||||
|
||||
|
||||
def _welcome_scan(conn) -> dict:
|
||||
"""
|
||||
Scan for unwelcomed branches and create welcome posts.
|
||||
|
||||
Reference in New Issue
Block a user