4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
259 lines
7.3 KiB
Python
259 lines
7.3 KiB
Python
"""``opensre cron`` command group: manage scheduled deliveries.
|
|
|
|
Provides CLI surface for creating, listing, removing, running, and
|
|
viewing logs of cron-driven scheduled tasks that deliver reports to
|
|
messaging providers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import click
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
_console = Console()
|
|
|
|
|
|
@click.group(name="cron")
|
|
def cron_command() -> None:
|
|
"""Manage cron-driven scheduled deliveries to messaging providers."""
|
|
|
|
|
|
@cron_command.command(name="add")
|
|
@click.option(
|
|
"--kind",
|
|
type=click.Choice(
|
|
[
|
|
"daily_summary",
|
|
"weekly_audit",
|
|
"incident_window_replay",
|
|
"synthetic_run",
|
|
"custom_investigation",
|
|
],
|
|
case_sensitive=False,
|
|
),
|
|
required=True,
|
|
help="The kind of scheduled task.",
|
|
)
|
|
@click.option(
|
|
"--cron",
|
|
"cron_expr",
|
|
type=str,
|
|
required=True,
|
|
help="Cron expression (5 fields: minute hour day month day_of_week).",
|
|
)
|
|
@click.option(
|
|
"--tz",
|
|
"timezone",
|
|
type=str,
|
|
default="UTC",
|
|
show_default=True,
|
|
help="IANA timezone for the schedule (e.g. Europe/London, US/Eastern).",
|
|
)
|
|
@click.option(
|
|
"--provider",
|
|
type=click.Choice(["telegram", "slack", "discord"], case_sensitive=False),
|
|
required=True,
|
|
help="Messaging provider for delivery.",
|
|
)
|
|
@click.option(
|
|
"--chat-id",
|
|
type=str,
|
|
required=True,
|
|
help="Chat/channel ID for the target provider.",
|
|
)
|
|
@click.option(
|
|
"--window",
|
|
"window_hours",
|
|
type=click.IntRange(min=1),
|
|
default=24,
|
|
show_default=True,
|
|
help="Lookback window in hours for the report (must be >= 1).",
|
|
)
|
|
def cron_add(
|
|
kind: str,
|
|
cron_expr: str,
|
|
timezone: str,
|
|
provider: str,
|
|
chat_id: str,
|
|
window_hours: int,
|
|
) -> None:
|
|
"""Add a new scheduled delivery task."""
|
|
from platform.scheduler.types import Provider, ScheduledTask, TaskKind
|
|
|
|
# Validate cron expression by constructing the APScheduler trigger
|
|
_validate_cron_and_timezone(cron_expr, timezone)
|
|
|
|
task = ScheduledTask(
|
|
kind=TaskKind(kind),
|
|
cron=cron_expr,
|
|
timezone=timezone,
|
|
provider=Provider(provider),
|
|
chat_id=chat_id,
|
|
window_hours=window_hours,
|
|
)
|
|
|
|
from platform.scheduler.store import add_task
|
|
|
|
added = add_task(task)
|
|
_console.print(f"[green]Task {added.id} created.[/green]")
|
|
_console.print(f" Kind: {added.kind.value} Cron: {added.cron} TZ: {added.timezone}")
|
|
_console.print(f" Provider: {added.provider.value} Chat: {added.chat_id}")
|
|
|
|
|
|
@cron_command.command(name="list")
|
|
def cron_list() -> None:
|
|
"""List all scheduled delivery tasks."""
|
|
from platform.scheduler.store import list_tasks
|
|
|
|
tasks = list_tasks()
|
|
if not tasks:
|
|
_console.print("[dim]No scheduled tasks configured.[/dim]")
|
|
return
|
|
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("ID", style="cyan")
|
|
table.add_column("Kind")
|
|
table.add_column("Cron")
|
|
table.add_column("TZ")
|
|
table.add_column("Provider")
|
|
table.add_column("Enabled")
|
|
table.add_column("Last Run")
|
|
|
|
for task in tasks:
|
|
table.add_row(
|
|
task.display_id(),
|
|
task.kind.value,
|
|
task.cron,
|
|
task.timezone,
|
|
task.provider.value,
|
|
"✓" if task.enabled else "✗",
|
|
task.last_run or "—",
|
|
)
|
|
|
|
_console.print(table)
|
|
|
|
|
|
@cron_command.command(name="remove")
|
|
@click.argument("task_id")
|
|
def cron_remove(task_id: str) -> None:
|
|
"""Remove a scheduled delivery task by ID."""
|
|
from platform.scheduler.store import remove_task
|
|
|
|
if remove_task(task_id):
|
|
_console.print(f"[green]Task {task_id} removed.[/green]")
|
|
else:
|
|
_console.print(f"[red]Error: task {task_id} not found.[/red]")
|
|
raise SystemExit(1)
|
|
|
|
|
|
@cron_command.command(name="run")
|
|
@click.argument("task_id")
|
|
def cron_run(task_id: str) -> None:
|
|
"""Run a scheduled task immediately (ad-hoc one-shot for debugging)."""
|
|
from platform.scheduler.runner import run_task_now
|
|
from platform.scheduler.store import get_task
|
|
from tools.investigation.scheduler_bootstrap import install as install_scheduler_runner
|
|
|
|
install_scheduler_runner()
|
|
|
|
task = get_task(task_id)
|
|
if task is None:
|
|
_console.print(f"[red]Error: task {task_id} not found.[/red]")
|
|
raise SystemExit(1)
|
|
|
|
_console.print(f"Running task {task_id} ({task.kind.value})...")
|
|
success = run_task_now(task_id)
|
|
if success:
|
|
_console.print("[green]Done.[/green]")
|
|
else:
|
|
_console.print("[red]Task execution failed. Check logs for details.[/red]")
|
|
raise SystemExit(1)
|
|
|
|
|
|
@cron_command.command(name="logs")
|
|
@click.argument("task_id")
|
|
@click.option(
|
|
"--limit",
|
|
type=click.IntRange(min=1),
|
|
default=20,
|
|
show_default=True,
|
|
help="Max number of runs to show (must be >= 1).",
|
|
)
|
|
def cron_logs(task_id: str, limit: int) -> None:
|
|
"""Show execution history for a scheduled task."""
|
|
from platform.scheduler.claim_store import get_runs
|
|
from platform.scheduler.store import get_task
|
|
|
|
task = get_task(task_id)
|
|
if task is None:
|
|
_console.print(f"[red]Error: task {task_id} not found.[/red]")
|
|
raise SystemExit(1)
|
|
|
|
runs = get_runs(task_id, limit=limit)
|
|
if not runs:
|
|
_console.print(f"[dim]No execution history for task {task_id}.[/dim]")
|
|
return
|
|
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("Started")
|
|
table.add_column("Status")
|
|
table.add_column("Message ID")
|
|
table.add_column("Error")
|
|
|
|
for run in runs:
|
|
status_style = (
|
|
"green"
|
|
if run.status.value == "success"
|
|
else "red"
|
|
if run.status.value == "failed"
|
|
else ""
|
|
)
|
|
table.add_row(
|
|
run.started_at,
|
|
f"[{status_style}]{run.status.value}[/{status_style}]"
|
|
if status_style
|
|
else run.status.value,
|
|
run.posted_message_id or "—",
|
|
run.error[:50] if run.error else "—",
|
|
)
|
|
|
|
_console.print(table)
|
|
|
|
|
|
@cron_command.command(name="start")
|
|
def cron_start() -> None:
|
|
"""Start the scheduler daemon (blocks until interrupted)."""
|
|
from platform.scheduler.runner import start_scheduler
|
|
from tools.investigation.scheduler_bootstrap import install as install_scheduler_runner
|
|
|
|
install_scheduler_runner()
|
|
|
|
_console.print("[bold]Starting scheduler daemon...[/bold]")
|
|
_console.print("Press Ctrl+C to stop.")
|
|
start_scheduler()
|
|
|
|
|
|
def _validate_cron_and_timezone(cron_expr: str, timezone: str) -> None:
|
|
"""Validate cron expression and timezone by constructing an APScheduler trigger.
|
|
|
|
Fails fast with a clear error message instead of creating inert tasks.
|
|
"""
|
|
parts = cron_expr.split()
|
|
if len(parts) != 5:
|
|
_console.print("[red]Error: cron expression must have exactly 5 fields.[/red]")
|
|
_console.print(" Format: minute hour day month day_of_week")
|
|
_console.print(" Example: 0 9 * * 1-5 (weekdays at 09:00)")
|
|
raise SystemExit(1)
|
|
|
|
try:
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
|
|
CronTrigger.from_crontab(cron_expr, timezone=timezone)
|
|
except (ValueError, TypeError, KeyError) as exc:
|
|
_console.print(f"[red]Error: invalid cron expression or timezone: {exc}[/red]")
|
|
raise SystemExit(1) from exc
|
|
|
|
|
|
__all__ = ["cron_command"]
|