561 lines
18 KiB
Python
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()
|