Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats

This commit is contained in:
Rose
2026-04-29 11:50:00 +02:00
parent c705fad626
commit 255914c9f1
43 changed files with 17948 additions and 6899 deletions

289
api/heartbeats.py Normal file
View 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