Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
289
api/heartbeats.py
Normal file
289
api/heartbeats.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Heartbeat System API for WebUI.
|
||||
Provides endpoints to manage heartbeats and monitor the manager/watchdog.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HEARTBEAT_DIR = Path.home() / ".hermes" / "heartbeat"
|
||||
REGISTRY_FILE = HEARTBEAT_DIR / "registry.json"
|
||||
MANAGER_SCRIPT = Path.home() / ".hermes" / "scripts" / "heartbeat_manager.py"
|
||||
WATCHDOG_LOG = Path.home() / ".hermes" / "logs" / "heartbeat_watchdog.log"
|
||||
MANAGER_LOG = Path.home() / ".hermes" / "logs" / "heartbeat_manager.log"
|
||||
HB_API = Path.home() / ".hermes" / "scripts" / "heartbeat_api.py"
|
||||
|
||||
def _run_api(args: list) -> dict:
|
||||
"""Run heartbeat_api.py with given args, return parsed JSON."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(HB_API)] + args,
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(Path.home() / ".hermes")
|
||||
)
|
||||
if result.returncode == 0:
|
||||
stdout = result.stdout.strip()
|
||||
# Try to parse JSON from stdout
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
# Plain text output = success
|
||||
return {"ok": True, "output": stdout}
|
||||
# Error case
|
||||
stderr = result.stderr.strip()
|
||||
if stderr:
|
||||
return {"error": stderr}
|
||||
return {"error": f"Exit code {result.returncode}"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "Command timed out"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _load_registry() -> dict:
|
||||
try:
|
||||
with REGISTRY_FILE.open(encoding="utf-8") as f:
|
||||
return json.loads(f.read())
|
||||
except Exception:
|
||||
return {"heartbeats": []}
|
||||
|
||||
|
||||
def _manager_pid() -> str | None:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "heartbeat_manager.py"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split()[0]
|
||||
return None
|
||||
|
||||
|
||||
def _manager_log_tail(lines: int = 20) -> str:
|
||||
try:
|
||||
if MANAGER_LOG.exists():
|
||||
all_lines = MANAGER_LOG.read_text().splitlines()
|
||||
return "\n".join(all_lines[-lines:])
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _watchdog_log_tail(lines: int = 10) -> str:
|
||||
try:
|
||||
if WATCHDOG_LOG.exists():
|
||||
all_lines = WATCHDOG_LOG.read_text().splitlines()
|
||||
return "\n".join(all_lines[-lines:])
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_get(path: str) -> dict:
|
||||
"""Handle GET /api/heartbeats/* routes."""
|
||||
if path == "/api/heartbeats":
|
||||
# List all heartbeats with status summary + manager info
|
||||
registry = _load_registry()
|
||||
heartbeats = registry.get("heartbeats", [])
|
||||
by_status = {}
|
||||
by_priority = {}
|
||||
by_source = {}
|
||||
pending_due = 0
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
for hb in heartbeats:
|
||||
s = hb.get("status", "unknown")
|
||||
by_status[s] = by_status.get(s, 0) + 1
|
||||
p = hb.get("priority", "normal")
|
||||
by_priority[p] = by_priority.get(p, 0) + 1
|
||||
src = hb.get("source", "unknown")
|
||||
by_source[src] = by_source.get(src, 0) + 1
|
||||
if s == "pending" and hb.get("trigger_at", "") <= now:
|
||||
pending_due += 1
|
||||
|
||||
# Manager info
|
||||
pid = _manager_pid()
|
||||
|
||||
return {
|
||||
"heartbeats": heartbeats,
|
||||
"total": len(heartbeats),
|
||||
"pending_due_count": pending_due,
|
||||
"by_status": by_status,
|
||||
"by_priority": by_priority,
|
||||
"by_source": by_source,
|
||||
"_manager": {
|
||||
"running": pid is not None,
|
||||
"pid": pid,
|
||||
}
|
||||
}
|
||||
|
||||
if path == "/api/heartbeats/manager":
|
||||
pid = _manager_pid()
|
||||
return {
|
||||
"running": pid is not None,
|
||||
"pid": pid,
|
||||
"log_tail": _manager_log_tail(15),
|
||||
}
|
||||
|
||||
if path == "/api/heartbeats/watchdog":
|
||||
return {
|
||||
"log_tail": _watchdog_log_tail(10),
|
||||
}
|
||||
|
||||
if path == "/api/heartbeats/stats":
|
||||
# Compute firing stats from log files
|
||||
import glob, re
|
||||
log_dir = HEARTBEAT_DIR / "logs"
|
||||
fired_24h = 0
|
||||
fired_total = 0
|
||||
now = datetime.now()
|
||||
day_ago = datetime.fromtimestamp(now.timestamp() - 86400)
|
||||
|
||||
for log_file in glob.glob(str(log_dir / "heartbeat_*.log")):
|
||||
try:
|
||||
for line in Path(log_file).read_text().splitlines():
|
||||
if "processed heartbeat" in line or "fired" in line.lower():
|
||||
# Parse timestamp from log line: [2026-04-28 08:17:54]
|
||||
m = re.match(r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]", line)
|
||||
if m:
|
||||
fired_total += 1
|
||||
try:
|
||||
dt = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
|
||||
if dt >= day_ago:
|
||||
fired_24h += 1
|
||||
except: pass
|
||||
except: pass
|
||||
|
||||
# Next scheduled heartbeat
|
||||
registry = _load_registry()
|
||||
next_hb = None
|
||||
for hb in registry.get("heartbeats", []):
|
||||
if hb.get("status") == "pending":
|
||||
ta = hb.get("trigger_at", "")
|
||||
if ta and (next_hb is None or ta < next_hb):
|
||||
next_hb = ta
|
||||
|
||||
# Load heartbeat.json config
|
||||
config_file = Path.home() / ".hermes" / "config" / "heartbeat.json"
|
||||
config = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
with config_file.open(encoding="utf-8") as f:
|
||||
config = json.loads(f.read())
|
||||
except: pass
|
||||
|
||||
return {
|
||||
"fired_total": fired_total,
|
||||
"fired_24h": fired_24h,
|
||||
"next_scheduled": next_hb,
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# GET /api/heartbeats/{id}
|
||||
if path.startswith("/api/heartbeats/"):
|
||||
hb_id = path.split("/")[-1]
|
||||
if hb_id in ("manager", "watchdog"):
|
||||
return {"error": "Not found"}, 404
|
||||
registry = _load_registry()
|
||||
for hb in registry.get("heartbeats", []):
|
||||
if hb.get("id") == hb_id:
|
||||
return hb
|
||||
return {"error": f"Heartbeat {hb_id} not found"}, 404
|
||||
|
||||
return None # Not handled
|
||||
|
||||
|
||||
def handle_post(path: str, body: dict) -> dict:
|
||||
"""Handle POST /api/heartbeats/* routes."""
|
||||
if path == "/api/heartbeats":
|
||||
# Create heartbeat
|
||||
source = body.get("source", "webui")
|
||||
action = body.get("action", "rose_continue")
|
||||
instruction = body.get("instruction", "")
|
||||
minutes = int(body.get("minutes", 5))
|
||||
priority = body.get("priority")
|
||||
mode = body.get("mode", "silent")
|
||||
recurring = bool(body.get("recurring", False))
|
||||
interval_minutes = int(body.get("interval_minutes", minutes)) if recurring else None
|
||||
max_iterations = int(body["max_iterations"]) if body.get("max_iterations") else None
|
||||
|
||||
args = [
|
||||
"create",
|
||||
"--source", source,
|
||||
"--action", action,
|
||||
"--instruction", instruction,
|
||||
"--minutes", str(minutes),
|
||||
"--mode", mode,
|
||||
]
|
||||
if priority:
|
||||
args += ["--priority", priority]
|
||||
if recurring:
|
||||
args.append("--recurring")
|
||||
if interval_minutes:
|
||||
args += ["--interval-minutes", str(interval_minutes)]
|
||||
if max_iterations:
|
||||
args += ["--max-iterations", str(max_iterations)]
|
||||
|
||||
result = _run_api(args)
|
||||
return result
|
||||
|
||||
if path == "/api/heartbeats/manager/restart":
|
||||
pid = _manager_pid()
|
||||
if pid:
|
||||
subprocess.run(["kill", pid], capture_output=True)
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(MANAGER_SCRIPT), "--daemon"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, cwd=str(Path.home() / ".hermes")
|
||||
)
|
||||
return {"ok": True, "message": "Manager restart initiated"}
|
||||
|
||||
if path.startswith("/api/heartbeats/") and path.endswith("/cancel"):
|
||||
hb_id = path.split("/")[-2]
|
||||
result = _run_api(["cancel", "--id", hb_id])
|
||||
return result
|
||||
|
||||
if path.startswith("/api/heartbeats/") and path.endswith("/fire"):
|
||||
# Manual fire (for testing)
|
||||
hb_id = path.split("/")[-2]
|
||||
# Simulate fire by updating trigger_at to now
|
||||
registry = _load_registry()
|
||||
for hb in registry.get("heartbeats", []):
|
||||
if hb.get("id") == hb_id:
|
||||
hb["trigger_at"] = datetime.now().isoformat()
|
||||
REGISTRY_FILE.write_text(json.dumps(registry, indent=2))
|
||||
return {"ok": True, "message": f"Heartbeat {hb_id} fire time set to now"}
|
||||
return {"error": f"Heartbeat {hb_id} not found"}, 404
|
||||
|
||||
if path == "/api/heartbeats/config":
|
||||
# Update heartbeat config (quiet hours, intervals, telegram)
|
||||
config_file = Path.home() / ".hermes" / "config" / "heartbeat.json"
|
||||
config = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
with config_file.open(encoding="utf-8") as f:
|
||||
config = json.loads(f.read())
|
||||
except: pass
|
||||
for key in ("quiet_hours", "daemon_interval_seconds", "intervals", "telegram", "critical_override"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
config_file.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
return {"ok": True, "config": config}
|
||||
|
||||
return None # Not handled
|
||||
|
||||
|
||||
def handle_delete(path: str) -> dict:
|
||||
"""Handle DELETE /api/heartbeats/{id}."""
|
||||
if path.startswith("/api/heartbeats/"):
|
||||
hb_id = path.split("/")[-1]
|
||||
if hb_id in ("manager", "watchdog"):
|
||||
return {"error": "Cannot delete system endpoint"}, 400
|
||||
result = _run_api(["cancel", "--id", hb_id])
|
||||
return result
|
||||
return None
|
||||
Reference in New Issue
Block a user