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
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Slash commands: /watch, /watches, /unwatch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import threading
|
|
from dataclasses import dataclass
|
|
|
|
from rich.console import Console
|
|
from rich.markup import escape
|
|
|
|
from integrations.telegram.alarms import AlarmDispatcher
|
|
from integrations.telegram.credentials import load_credentials_from_env
|
|
from platform.common.errors import OpenSREError
|
|
from surfaces.interactive_shell.command_registry.types import (
|
|
SlashCommand,
|
|
)
|
|
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord, TaskStatus
|
|
from surfaces.interactive_shell.ui import (
|
|
BOLD_BRAND,
|
|
DIM,
|
|
ERROR,
|
|
HIGHLIGHT,
|
|
WARNING,
|
|
print_repl_table,
|
|
repl_table,
|
|
)
|
|
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
|
|
from tools.system.fleet_monitoring.probe import pid_exists
|
|
from tools.system.watch_dog.monitor import start_watchdog_daemon_thread
|
|
|
|
_PID_IN_COMMAND_RE = re.compile(r"pid=(\d+)")
|
|
_CONSOLE_PRINT_LOCK = threading.Lock()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WatchdogStartSpec:
|
|
pid: int
|
|
max_cpu: float | None = None
|
|
max_runtime_seconds: float | None = None
|
|
max_rss_mib: float | None = None
|
|
cooldown_seconds: float = 300.0
|
|
interval_seconds: float = 2.0
|
|
once: bool = False
|
|
|
|
|
|
def _parse_duration_seconds(raw: str) -> float | None:
|
|
text = raw.strip().lower()
|
|
if not text:
|
|
return None
|
|
mult = 1.0
|
|
if text.endswith("h"):
|
|
mult = 3600.0
|
|
text = text[:-1]
|
|
elif text.endswith("m") and not text.endswith("ms"):
|
|
mult = 60.0
|
|
text = text[:-1]
|
|
elif text.endswith("s"):
|
|
mult = 1.0
|
|
text = text[:-1]
|
|
try:
|
|
return float(text) * mult
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _parse_mib(raw: str) -> float | None:
|
|
text = raw.strip().lower()
|
|
if not text:
|
|
return None
|
|
mult = 1.0
|
|
if text.endswith("gib") or text.endswith("gb") or text.endswith("g"):
|
|
mult = 1024.0
|
|
for suffix in ("gib", "gb", "g"):
|
|
if text.endswith(suffix):
|
|
text = text[: -len(suffix)]
|
|
break
|
|
elif text.endswith("mib") or text.endswith("mb") or text.endswith("m"):
|
|
mult = 1.0
|
|
for suffix in ("mib", "mb", "m"):
|
|
if text.endswith(suffix):
|
|
text = text[: -len(suffix)]
|
|
break
|
|
try:
|
|
return float(text) * mult
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_watch_argv(argv: list[str]) -> WatchdogStartSpec | str:
|
|
"""Parse ``/watch`` arguments (tokens after the command name)."""
|
|
if not argv:
|
|
return (
|
|
f"[{ERROR}]usage:[/] /watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
|
|
f"[--cooldown D] [--interval N] [--once]"
|
|
)
|
|
if argv[0].startswith("-"):
|
|
return f"[{ERROR}]usage:[/] /watch <pid> ... — the process id must come first"
|
|
|
|
try:
|
|
pid = int(argv[0], 10)
|
|
except ValueError:
|
|
return f"[{ERROR}]invalid pid:[/] {escape(argv[0])}"
|
|
if pid <= 0:
|
|
return f"[{ERROR}]pid must be a positive integer:[/] {pid}"
|
|
|
|
max_cpu: float | None = None
|
|
max_runtime_seconds: float | None = None
|
|
max_rss_mib: float | None = None
|
|
cooldown_seconds = 300.0
|
|
interval_seconds = 2.0
|
|
once = False
|
|
|
|
i = 1
|
|
while i < len(argv):
|
|
token = argv[i]
|
|
if token == "--once":
|
|
once = True
|
|
i += 1
|
|
continue
|
|
if not token.startswith("--"):
|
|
return f"[{ERROR}]unexpected argument:[/] {escape(token)}"
|
|
if i + 1 >= len(argv):
|
|
return f"[{ERROR}]missing value for[/] {escape(token)}"
|
|
value = argv[i + 1]
|
|
i += 2
|
|
if token == "--max-cpu":
|
|
try:
|
|
pct = float(value)
|
|
except ValueError:
|
|
return f"[{ERROR}]invalid --max-cpu:[/] {escape(value)}"
|
|
max_supported_cpu = 100.0 * float(max(1, os.cpu_count() or 1))
|
|
if pct <= 0 or pct > max_supported_cpu:
|
|
return f"[{ERROR}]--max-cpu must be between 0 and {max_supported_cpu:g}:[/] {pct}"
|
|
max_cpu = pct
|
|
elif token == "--max-runtime":
|
|
seconds = _parse_duration_seconds(value)
|
|
if seconds is None or seconds <= 0:
|
|
return f"[{ERROR}]invalid --max-runtime:[/] {escape(value)}"
|
|
max_runtime_seconds = seconds
|
|
elif token == "--max-rss":
|
|
mib = _parse_mib(value)
|
|
if mib is None or mib <= 0:
|
|
return f"[{ERROR}]invalid --max-rss:[/] {escape(value)}"
|
|
max_rss_mib = mib
|
|
elif token == "--cooldown":
|
|
seconds = _parse_duration_seconds(value)
|
|
if seconds is None or seconds <= 0:
|
|
return f"[{ERROR}]invalid --cooldown:[/] {escape(value)}"
|
|
cooldown_seconds = seconds
|
|
elif token == "--interval":
|
|
seconds = _parse_duration_seconds(value)
|
|
if seconds is None or seconds <= 0:
|
|
return f"[{ERROR}]invalid --interval:[/] {escape(value)}"
|
|
interval_seconds = seconds
|
|
else:
|
|
return f"[{ERROR}]unknown flag:[/] {escape(token)}"
|
|
|
|
return WatchdogStartSpec(
|
|
pid=pid,
|
|
max_cpu=max_cpu,
|
|
max_runtime_seconds=max_runtime_seconds,
|
|
max_rss_mib=max_rss_mib,
|
|
cooldown_seconds=cooldown_seconds,
|
|
interval_seconds=interval_seconds,
|
|
once=once,
|
|
)
|
|
|
|
|
|
def _watch_command_summary(spec: WatchdogStartSpec) -> str:
|
|
parts = [f"watchdog pid={spec.pid}"]
|
|
if spec.max_cpu is not None:
|
|
parts.append(f"max_cpu={spec.max_cpu:g}%")
|
|
if spec.max_runtime_seconds is not None:
|
|
parts.append(f"max_runtime={spec.max_runtime_seconds:g}s")
|
|
if spec.max_rss_mib is not None:
|
|
parts.append(f"max_rss={spec.max_rss_mib:g}MiB")
|
|
parts.append(f"cooldown={spec.cooldown_seconds:g}s")
|
|
parts.append(f"interval={spec.interval_seconds:g}s")
|
|
if spec.once:
|
|
parts.append("once")
|
|
return " ".join(parts)
|
|
|
|
|
|
def _watched_pid_from_task(task: TaskRecord) -> str:
|
|
if task.command:
|
|
match = _PID_IN_COMMAND_RE.search(task.command)
|
|
if match:
|
|
return match.group(1)
|
|
return "—"
|
|
|
|
|
|
def _cmd_watch(session: Session, console: Console, args: list[str]) -> bool:
|
|
parsed = parse_watch_argv(args)
|
|
if isinstance(parsed, str):
|
|
console.print(parsed)
|
|
return True
|
|
|
|
if not pid_exists(parsed.pid):
|
|
console.print(f"[{ERROR}]no such process:[/] pid {parsed.pid}")
|
|
return True
|
|
|
|
try:
|
|
creds = load_credentials_from_env()
|
|
except OpenSREError as exc:
|
|
console.print(f"[{ERROR}]{escape(str(exc))}[/]")
|
|
return True
|
|
|
|
summary = _watch_command_summary(parsed)
|
|
task = session.task_registry.create(TaskKind.WATCHDOG, command=summary)
|
|
task.mark_running()
|
|
task.attach_pid(parsed.pid)
|
|
dispatcher = AlarmDispatcher(creds, cooldown_seconds=parsed.cooldown_seconds)
|
|
|
|
def _on_alarm(threshold: str, detail: str) -> None:
|
|
with _CONSOLE_PRINT_LOCK:
|
|
console.print(
|
|
f"[task {escape(task.task_id)}] alarm fired: {escape(threshold)} "
|
|
f"{escape(detail)} (telegram delivered)"
|
|
)
|
|
|
|
start_watchdog_daemon_thread(
|
|
task=task,
|
|
watched_pid=parsed.pid,
|
|
interval_seconds=parsed.interval_seconds,
|
|
max_cpu=parsed.max_cpu,
|
|
max_runtime_seconds=parsed.max_runtime_seconds,
|
|
max_rss_mib=parsed.max_rss_mib,
|
|
once=parsed.once,
|
|
dispatcher=dispatcher,
|
|
on_alarm=_on_alarm,
|
|
)
|
|
console.print(f"[{DIM}]task[/] [bold]{escape(task.task_id)}[/bold] [{DIM}]started.[/]")
|
|
return True
|
|
|
|
|
|
def _cmd_watches(session: Session, console: Console, _args: list[str]) -> bool:
|
|
rows = [t for t in session.task_registry.list_recent(n=100) if t.kind == TaskKind.WATCHDOG]
|
|
if not rows:
|
|
console.print(f"[{DIM}]no watchdog tasks in this session.[/]")
|
|
return True
|
|
|
|
table = repl_table(title="Watchdogs\n", title_style=BOLD_BRAND)
|
|
table.add_column("id", style="bold")
|
|
table.add_column("pid", justify="right")
|
|
table.add_column("kind")
|
|
table.add_column("status")
|
|
table.add_column("started", style=DIM)
|
|
table.add_column("thresholds", overflow="fold")
|
|
table.add_column("last sample", style=DIM, overflow="fold")
|
|
|
|
status_style = {
|
|
TaskStatus.RUNNING: WARNING,
|
|
TaskStatus.COMPLETED: HIGHLIGHT,
|
|
TaskStatus.CANCELLED: WARNING,
|
|
TaskStatus.FAILED: ERROR,
|
|
TaskStatus.PENDING: DIM,
|
|
}
|
|
for task in rows:
|
|
st = status_style.get(task.status, DIM)
|
|
started = format_repl_timestamp(task.started_at, style="utc")
|
|
thresholds = task.command or "—"
|
|
sample = task.progress or "—"
|
|
table.add_row(
|
|
task.task_id,
|
|
_watched_pid_from_task(task),
|
|
TaskKind.WATCHDOG.value,
|
|
f"[{st}]{task.status.value}[/]",
|
|
started,
|
|
escape(thresholds),
|
|
escape(sample),
|
|
)
|
|
print_repl_table(console, table)
|
|
return True
|
|
|
|
|
|
def _validate_unwatch_args(args: list[str]) -> str | None:
|
|
if not args:
|
|
return f"[{ERROR}]usage:[/] /unwatch <task_id> — use [{HIGHLIGHT}]/watches[/] to list ids"
|
|
return None
|
|
|
|
|
|
def _cmd_unwatch(session: Session, console: Console, args: list[str]) -> bool:
|
|
needle = args[0]
|
|
candidates = session.task_registry.candidates(needle)
|
|
if not candidates:
|
|
console.print(f"[{ERROR}]no task matches id:[/] {escape(needle)}")
|
|
return True
|
|
if len(candidates) > 1:
|
|
console.print(
|
|
f"[{ERROR}]ambiguous id prefix:[/] {escape(needle)} "
|
|
f"[{DIM}]({len(candidates)} matches — use a longer prefix)[/]"
|
|
)
|
|
return True
|
|
|
|
task = candidates[0]
|
|
if task.kind != TaskKind.WATCHDOG:
|
|
console.print(
|
|
f"[{ERROR}]task {escape(task.task_id)} is not a watchdog "
|
|
f"(kind: {escape(task.kind.value)}); use /cancel instead.[/]"
|
|
)
|
|
return True
|
|
if task.status != TaskStatus.RUNNING:
|
|
console.print(
|
|
f"[{DIM}]task {escape(task.task_id)} already finished (status: {task.status.value}).[/]"
|
|
)
|
|
return True
|
|
|
|
task.request_cancel()
|
|
console.print(
|
|
f"[{HIGHLIGHT}]stop requested[/] "
|
|
f"[{DIM}]for watchdog {escape(task.task_id)}.[/] "
|
|
f"[{DIM}]use[/] [{HIGHLIGHT}]/watches[/] [{DIM}]to confirm status.[/]"
|
|
)
|
|
return True
|
|
|
|
|
|
def _validate_watch_args(args: list[str]) -> str | None:
|
|
if not args:
|
|
return (
|
|
f"[{ERROR}]usage:[/] /watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
|
|
f"[--cooldown D] [--interval N] [--once]"
|
|
)
|
|
return None
|
|
|
|
|
|
COMMANDS: list[SlashCommand] = [
|
|
SlashCommand(
|
|
"/watch",
|
|
"Watch a process and send threshold alarms.",
|
|
_cmd_watch,
|
|
usage=(
|
|
"/watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
|
|
"[--cooldown D] [--interval N] [--once]",
|
|
),
|
|
notes=("Alarms are sent to Telegram when Telegram delivery is configured.",),
|
|
validate_args=_validate_watch_args,
|
|
),
|
|
SlashCommand(
|
|
"/watches",
|
|
"List watchdog background tasks with the latest resource sample.",
|
|
_cmd_watches,
|
|
usage=("/watches",),
|
|
),
|
|
SlashCommand(
|
|
"/unwatch",
|
|
"Cancel a running watchdog task by id.",
|
|
_cmd_unwatch,
|
|
usage=("/unwatch <task_id>",),
|
|
notes=("Use /watches to list watchdog task ids.",),
|
|
validate_args=_validate_unwatch_args,
|
|
),
|
|
]
|
|
|
|
__all__ = ["COMMANDS", "WatchdogStartSpec", "parse_watch_argv"]
|