feat(daemon): Scheduler Bot Phase 1 — unified queue + status capture + lifecycle telegram pings (TDPLAN-0008)
- One queue: archive dormant task_registry + actions_registry (+5 tests) to .archive/, retire schedule/actions CLIs; .daemon/schedule.json is now the single source - Status capture: runstate gains last_status/last_error/last_success_at/last_failure_at; persisted on success AND failure paths (was success-only) - Unified view: drone @daemon queue + --json (frozen schema), aggregates .daemon/*.json joined to runstate - Lifecycle pings: un-archived telegram_notifier wired to @skills send_telegram_notification (fail-soft, per-job notify flag, zero calls on empty ticks) - 24 new tests (327 pass), seedgo 98%; verified live end-to-end (queue --json schema + real telegram delivery via daemon wrapper)
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: queue.py
|
||||
# Description: Unified job queue view (drone @daemon queue)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Unified queue view — aggregates .daemon/schedule.json jobs joined to runstate.
|
||||
|
||||
Human-readable Rich table (default) or --json matching the frozen contract
|
||||
consumed by @skills' scheduler bot.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
from aipass.daemon.apps.handlers.schedule.discovery import discover_jobs
|
||||
from aipass.daemon.apps.handlers.schedule.runstate import (
|
||||
load_runstate,
|
||||
get_job_state,
|
||||
)
|
||||
|
||||
HANDLED_COMMANDS = {"queue"}
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]queue Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Unified job queue view — .daemon/ jobs joined to runstate[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Reads:[/yellow]")
|
||||
console.print(" [cyan]*[/cyan] src/aipass/*/.daemon/*.json [dim](per-branch schedule files)[/dim]")
|
||||
console.print(" [cyan]*[/cyan] daemon_json/daemon_runstate.json [dim](last_run/status state)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display usage information."""
|
||||
console.print("\n[bold cyan]queue — Unified Job Queue View[/bold cyan]")
|
||||
console.print("\n[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon queue Show job queue (Rich table)")
|
||||
console.print(" drone @daemon queue --json Machine-readable JSON (frozen schema)")
|
||||
console.print(" drone @daemon queue --help Show this help message")
|
||||
console.print()
|
||||
|
||||
|
||||
def _schedule_human(job: dict) -> str:
|
||||
"""Build human-readable schedule string."""
|
||||
sched = job.get("schedule", {})
|
||||
sched_type = sched.get("type", "")
|
||||
if sched_type == "once":
|
||||
return sched.get("due_date", "?")
|
||||
if sched_type == "daily":
|
||||
return f"daily @ {sched.get('time', '??:??')}"
|
||||
if sched_type == "hourly":
|
||||
m = sched.get("time", "0")
|
||||
return f"hourly @ :{int(m):02d}"
|
||||
if sched_type == "interval":
|
||||
mins = sched.get("interval_minutes", 0)
|
||||
if mins >= 60:
|
||||
return f"every {mins // 60}h"
|
||||
return f"every {mins}m"
|
||||
return sched_type
|
||||
|
||||
|
||||
def _compute_next_run(job: dict, state: dict) -> Optional[str]:
|
||||
"""Determine next_run from runstate or schedule."""
|
||||
if state.get("completed"):
|
||||
return None
|
||||
next_run = state.get("next_run")
|
||||
if next_run:
|
||||
return next_run
|
||||
sched = job.get("schedule", {})
|
||||
if sched.get("type") == "once":
|
||||
due = sched.get("due_date")
|
||||
if due and "T" not in due:
|
||||
return f"{due}T09:00:00"
|
||||
return due
|
||||
return None
|
||||
|
||||
|
||||
def _build_queue(jobs: list, runstate: dict) -> list:
|
||||
"""Build unified queue entries from discovered jobs + runstate."""
|
||||
entries = []
|
||||
for job in jobs:
|
||||
state = get_job_state(runstate, job["owner"], job["id"])
|
||||
if state.get("completed"):
|
||||
continue
|
||||
|
||||
owner = job["owner"]
|
||||
if owner.startswith("@"):
|
||||
pass
|
||||
elif "@" in owner:
|
||||
owner = f"@{owner.split('@')[0]}"
|
||||
|
||||
prompt = job.get("prompt", "")
|
||||
preview = prompt[:80] + "..." if len(prompt) > 80 else prompt
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"owner": owner,
|
||||
"id": job["id"],
|
||||
"enabled": job.get("enabled", True),
|
||||
"type": job["schedule"].get("type", ""),
|
||||
"schedule_human": _schedule_human(job),
|
||||
"next_run": _compute_next_run(job, state),
|
||||
"last_run": state.get("last_run"),
|
||||
"last_status": state.get("last_status"),
|
||||
"last_error": state.get("last_error"),
|
||||
"prompt_preview": preview,
|
||||
"wake": job.get("wake", {}),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _print_rich_table(entries: list) -> None:
|
||||
"""Print queue as a Rich table."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Job Queue[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
if not entries:
|
||||
console.print("[dim]No jobs in queue.[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
console.print(
|
||||
f" {'OWNER':<14} {'ID':<20} {'ON':<4} {'TYPE':<9} {'SCHEDULE':<18} {'LAST STATUS':<12} {'NEXT RUN':<20}"
|
||||
)
|
||||
console.print(" " + "-" * 97)
|
||||
|
||||
for e in entries:
|
||||
enabled = "[green]ON[/green] " if e["enabled"] else "[red]OFF[/red]"
|
||||
last_status = e.get("last_status") or "-"
|
||||
next_run = (e.get("next_run") or "-")[:19]
|
||||
console.print(
|
||||
f" {e['owner']:<14} {e['id']:<20} {enabled:<4} {e['type']:<9} "
|
||||
f"{e['schedule_human']:<18} {last_status:<12} {next_run:<20}"
|
||||
)
|
||||
|
||||
console.print()
|
||||
enabled_count = sum(1 for e in entries if e["enabled"])
|
||||
console.print(f" [dim]Total: {len(entries)} job(s) ({enabled_count} enabled)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _build_json_output(entries: list) -> dict:
|
||||
"""Build frozen-schema JSON output for @skills consumption."""
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"count": len(entries),
|
||||
"jobs": entries,
|
||||
}
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle 'queue' command from daemon CLI router."""
|
||||
if command not in HANDLED_COMMANDS:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
json_handler.log_operation("queue_command", {"json": "--json" in args})
|
||||
|
||||
jobs = discover_jobs()
|
||||
runstate = load_runstate()
|
||||
entries = _build_queue(jobs, runstate)
|
||||
|
||||
if "--json" in args:
|
||||
output = _build_json_output(entries)
|
||||
console.print(json.dumps(output, indent=2))
|
||||
else:
|
||||
_print_rich_table(entries)
|
||||
|
||||
logger.info("[queue] Queue displayed (%d jobs)", len(entries))
|
||||
return True
|
||||
Reference in New Issue
Block a user