Files
AIPass/src/aipass/daemon/apps/modules/actions.py
T

561 lines
18 KiB
Python

# =================== AIPass ====================
# Name: actions.py
# Description: Action Registry CLI Module
# Version: 1.0.0
# Created: 2026-03-02
# Modified: 2026-03-02
# =============================================
"""
CLI interface for the numbered action registry.
"""
# =============================================
# IMPORTS
# =============================================
import sys
from typing import List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error as cli_error
from aipass.daemon.apps.handlers.actions.actions_registry import (
list_actions,
get_action,
toggle_action,
delete_action,
create_action,
migrate_plugins,
next_due_str,
)
from aipass.daemon.apps.handlers.json import json_handler
def _header(text):
console.print(f"\n[bold cyan]{'=' * 70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'=' * 70}[/bold cyan]")
def _success(text):
console.print(f"[green]OK:[/green] {text}")
def _error(text):
cli_error(text)
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "actions"
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]actions Module[/bold cyan]")
console.print()
console.print("[dim]CLI interface for the numbered action registry (DPLAN-043)[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/actions/")
console.print(
" [cyan]*[/cyan] actions_registry.py"
" [dim](list_actions, get_action,"
" toggle_action, delete_action, create_action,"
" migrate_plugins, next_due_str — registry CRUD)[/dim]"
)
console.print()
# =============================================
# OUTPUT FORMATTING
# =============================================
def _format_schedule(action: dict) -> str:
"""Build schedule display string for an action."""
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
return f"daily @ {action.get('time', '??:??')}"
if schedule_type == "hourly":
m = action.get("time", "0")
return f"hourly @ :{int(m):02d}"
if schedule_type == "interval":
mins = action.get("interval_minutes", 0)
if mins >= 60:
return f"every {mins // 60}h"
return f"every {mins}m"
if schedule_type == "once":
return f"once: {action.get('due_date', '?')}"
return schedule_type
def _print_actions_table(actions: list) -> None:
"""Display formatted action list as a table."""
console.print()
_header("Action Registry")
console.print()
if not actions:
console.print("[dim]No actions registered. Run 'actions migrate' to import plugins.[/dim]")
console.print()
return
# Header row
console.print(f" {'ID':<6} {'ON':<4} {'NAME':<24} {'TYPE':<10} {'TARGET':<16} {'SCHEDULE':<20} {'NEXT DUE':<16}")
console.print(" " + "-" * 96)
for action in actions:
action_id = action.get("id", "????")
enabled = "[green]ON[/green] " if action.get("enabled") else "[red]OFF[/red]"
name = action.get("name", "")[:22]
action_type = action.get("type", "")[:8]
target = action.get("target_branch", "")[:14]
schedule_str = _format_schedule(action)
next_due = next_due_str(action)
console.print(
f" {action_id:<6} {enabled:<4} {name:<24} {action_type:<10} "
f" {target:<16} {schedule_str:<20} {next_due:<16}"
)
console.print()
enabled_count = sum(1 for a in actions if a.get("enabled"))
console.print(f" [dim]Total: {len(actions)} actions ({enabled_count} enabled)[/dim]")
console.print()
def _print_action_detail(action: dict) -> None:
"""Display detailed view of a single action."""
console.print()
_header(f"Action {action['id']}: {action['name']}")
console.print()
fields = [
("ID", action.get("id")),
("Name", action.get("name")),
("Type", action.get("type")),
("Enabled", "[green]ON[/green]" if action.get("enabled") else "[red]OFF[/red]"),
("Schedule", action.get("schedule_type")),
("Time", action.get("time")),
("Interval", f"{action.get('interval_minutes')}m" if action.get("interval_minutes") else None),
("Due Date", action.get("due_date")),
("Target", action.get("target_branch")),
("Fresh", action.get("fresh")),
("Max Turns", action.get("max_turns")),
("Self Dispatch", action.get("self_dispatch")),
("Plugin File", action.get("plugin_file")),
("Last Run", action.get("last_run", "never")[:19] if action.get("last_run") else "never"),
("Next Run", next_due_str(action)),
("Created", action.get("created", "")[:19]),
("Completed", action.get("completed")),
]
for label, value in fields:
if value is None:
continue
console.print(f" [cyan]{label:<16}[/cyan] {value}")
# Show prompt (truncated for readability)
prompt = action.get("prompt", "")
if prompt:
console.print()
console.print(" [cyan]Prompt:[/cyan]")
# Show first 200 chars
display_prompt = prompt[:200]
if len(prompt) > 200:
display_prompt += "..."
for line in display_prompt.split("\n"):
console.print(f" [dim]{line}[/dim]")
console.print()
def print_help() -> None:
"""Display help using Rich formatted output."""
console.print()
_header("Actions -- Numbered Action Registry")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" drone @daemon actions list")
console.print(" drone @daemon actions <id> info")
console.print(" drone @daemon actions <id> on")
console.print(" drone @daemon actions <id> off")
console.print(' drone @daemon actions set reminder <date> "message" [--to @branch]')
console.print(' drone @daemon actions set schedule @branch "prompt" <type> [time]')
console.print(" drone @daemon actions migrate")
console.print(" drone @daemon actions delete <id>")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" list List all registered actions with status")
console.print(" <id> info Show detailed view of a single action")
console.print(" <id> on Enable an action")
console.print(" <id> off Disable an action")
console.print(" set Create a new reminder or schedule")
console.print(" migrate Import existing plugins into registry")
console.print(" delete <id> Remove an action from the registry")
console.print()
console.print("[yellow]SET REMINDER:[/yellow]")
console.print(' set reminder 2026-03-11 "Check VERA progress"')
console.print(' set reminder 7d "Follow up on PR review" --to @flow')
console.print(" [dim]Date formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
console.print()
console.print("[yellow]SET SCHEDULE:[/yellow]")
console.print(' set schedule @seedgo "Run audit" daily 04:00')
console.print(' set schedule @daemon "Heartbeat" interval 240')
console.print(' set schedule @flow "Check plans" hourly 30')
console.print(" [dim]Types: daily HH:MM, hourly MM, interval MINUTES[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" actions list # See all actions")
console.print(" actions 0003 off # Disable action 3")
console.print(" actions 0003 on # Re-enable it")
console.print(' actions set reminder 2026-03-11 "check VERA" # One-shot reminder')
console.print()
# =============================================
# SUBCOMMAND HANDLERS
# =============================================
def _handle_list(_args: List[str]) -> bool:
"""Handle 'actions list' subcommand."""
actions = list_actions()
_print_actions_table(actions)
logger.info("[DAEMON] actions: Action list displayed")
return True
def _handle_toggle(action_id: str, enable: bool) -> bool:
"""Handle 'actions <id> on/off' subcommand."""
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
toggle_action(action_id, enable)
state = "enabled" if enable else "disabled"
_success(f"Action {action_id} ({action['name']}) {state}")
logger.info("[DAEMON] actions: Action toggled")
return True
def _handle_info(action_id: str) -> bool:
"""Handle 'actions <id> info' subcommand."""
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
_print_action_detail(action)
logger.info("[DAEMON] actions: Action info displayed")
return True
def _handle_set_reminder(args: List[str]) -> bool:
"""Handle 'actions set reminder <date> "message" [--to @branch]'."""
if len(args) < 2:
_error('Usage: actions set reminder <date> "message" [--to @branch]')
return True # Error displayed
date_str = args[0]
message = args[1]
target_branch = "@devpulse" # Default reminder target
# Parse --to flag
if "--to" in args:
to_idx = args.index("--to")
if to_idx + 1 < len(args):
target_branch = args[to_idx + 1]
# Parse date
due_date = _parse_date(date_str)
if not due_date:
_error(f"Invalid date format: {date_str}")
console.print("[dim]Valid formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
return True # Error displayed
action = create_action(
name=message[:50],
action_type="reminder",
schedule_type="once",
target_branch=target_branch,
prompt=message,
due_date=due_date,
fresh=True,
max_turns=10,
enabled=True,
)
_success(f"Reminder created: {action['id']}")
console.print(f" [dim]Due:[/dim] {due_date}")
console.print(f" [dim]To:[/dim] {target_branch}")
console.print(f" [dim]Message:[/dim] {message[:60]}")
console.print()
logger.info("[DAEMON] actions: Reminder set")
return True
def _handle_set_schedule(args: List[str]) -> bool:
"""Handle 'actions set schedule @branch "prompt" <type> [time_spec]'."""
if len(args) < 3:
_error('Usage: actions set schedule @branch "prompt" <daily|hourly|interval> [time_spec]')
return True # Error displayed
target_branch = args[0]
prompt = args[1]
schedule_type = args[2]
time_val = None
interval_minutes = None
if schedule_type not in ("daily", "hourly", "interval"):
_error(f"Unknown schedule type: {schedule_type}")
console.print("[dim]Valid types: daily, hourly, interval[/dim]")
return True # Error displayed
if len(args) < 4:
_error(f"{schedule_type.title()} schedule requires a time/value argument")
return True # Error displayed
if schedule_type in ("daily", "hourly"):
time_val = args[3]
else:
try:
interval_minutes = int(args[3])
except ValueError:
logger.warning("Invalid interval minutes value: %s", args[3])
_error(f"Invalid interval minutes: {args[3]}")
return True # Error displayed
# Generate a name from the prompt
name = prompt[:50].replace(" ", "_").lower()
action = create_action(
name=name,
action_type="schedule",
schedule_type=schedule_type,
target_branch=target_branch,
prompt=prompt,
time=time_val,
interval_minutes=interval_minutes,
fresh=True,
max_turns=50,
enabled=True,
)
_success(f"Schedule created: {action['id']}")
console.print(f" [dim]Name:[/dim] {action['name']}")
console.print(f" [dim]Target:[/dim] {target_branch}")
console.print(f" [dim]Type:[/dim] {schedule_type}")
if time_val:
console.print(f" [dim]Time:[/dim] {time_val}")
if interval_minutes:
console.print(f" [dim]Every:[/dim] {interval_minutes} minutes")
console.print()
logger.info("[DAEMON] actions: Schedule set")
return True
def _handle_migrate(_args: List[str]) -> bool:
"""Handle 'actions migrate' -- import plugins into registry."""
console.print()
console.print("[dim]Scanning plugins/ for unregistered plugins...[/dim]")
count = migrate_plugins()
if count > 0:
_success(f"Migrated {count} plugin(s) into the action registry")
else:
console.print("[dim]All plugins already registered (or none found).[/dim]")
# Show the updated list
actions = list_actions()
_print_actions_table(actions)
logger.info("[DAEMON] actions: Plugin migration completed")
return True
def _handle_delete(args: List[str]) -> bool:
"""Handle 'actions delete <id>'."""
if not args:
_error("Action ID required: actions delete <id>")
return True # Error displayed
action_id = args[0]
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
delete_action(action_id)
_success(f"Deleted action {action_id}: {action['name']}")
logger.info("[DAEMON] actions: Action deleted")
return True
# =============================================
# DATE PARSING
# =============================================
def _parse_date(date_str: str) -> str:
"""
Parse a date string into ISO format.
Supports: YYYY-MM-DD, 1d, 7d, 1w, 2w
Returns:
ISO date string or empty string on failure.
"""
from datetime import datetime, timedelta
date_str = date_str.strip()
# Relative dates
if date_str.endswith("d"):
try:
days = int(date_str[:-1])
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
except ValueError:
logger.warning("Invalid relative day format: %s", date_str)
return ""
elif date_str.endswith("w"):
try:
weeks = int(date_str[:-1])
return (datetime.now() + timedelta(weeks=weeks)).strftime("%Y-%m-%d")
except ValueError:
logger.warning("Invalid relative week format: %s", date_str)
return ""
# ISO date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
logger.warning("Invalid ISO date format: %s", date_str)
return ""
# =============================================
# ORCHESTRATION
# =============================================
def _route_set_subcommand(args: List[str]) -> bool:
"""Route 'actions set reminder ...' / 'actions set schedule ...'."""
if len(args) < 2:
_error("Usage: actions set <reminder|schedule> ...")
return True # Error displayed
set_type = args[1]
if set_type == "reminder":
return _handle_set_reminder(args[2:])
if set_type == "schedule":
return _handle_set_schedule(args[2:])
_error(f"Unknown set type: {set_type}. Use 'reminder' or 'schedule'.")
return True # Error displayed
def _route_action_id(action_id: str, args: List[str]) -> bool:
"""Route 'actions <4-digit-id> [on|off|info]'."""
if len(args) < 2:
return _handle_info(action_id)
sub_action = args[1]
if sub_action == "on":
return _handle_toggle(action_id, True)
if sub_action == "off":
return _handle_toggle(action_id, False)
if sub_action == "info":
return _handle_info(action_id)
_error(f"Unknown action command: {sub_action}. Use 'on', 'off', or 'info'.")
return True # Error displayed
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle 'actions' command and route to subcommands.
Args:
command: Command name (should be 'actions')
args: Command arguments
Returns:
True if handled, False otherwise
"""
if command != "actions":
return False
try:
# No args -- introspection gate
if not args:
print_introspection()
return True
# Help flag
if args[0] in ["--help", "-h", "help"]:
print_help()
return True
subcommand = args[0]
json_handler.log_operation("actions_command", {"subcommand": args[0] if args else "introspection"})
# Named subcommands
if subcommand == "list":
return _handle_list(args[1:])
if subcommand == "migrate":
return _handle_migrate(args[1:])
if subcommand == "delete":
return _handle_delete(args[1:])
if subcommand == "set":
return _route_set_subcommand(args)
# Check if first arg is an action ID (4-digit numeric)
if subcommand.isdigit() and len(subcommand) == 4:
return _route_action_id(subcommand, args)
_error(f"Unknown subcommand: {subcommand}")
console.print("[dim]Run 'actions --help' for available commands[/dim]")
return True # Command was handled (error displayed)
except Exception as e:
logger.error("[actions] Error in actions command: %s", e, exc_info=True)
_error(f"Error: {e}")
return True # Error displayed
# =============================================
# MAIN ENTRY
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
args = sys.argv[1:]
if not args or args[0] in ["--help", "-h", "help"]:
print_help()
return
handle_command("actions", args)
if __name__ == "__main__":
main()