Phase 1: Activity and Error Log for Agent Tab

Backend: _log_agent_activity, _get_activity_log, _get_error_log. API: GET /api/agents/{id}/activity and /errors. Frontend: Activity and Errors tabs in agent detail overlay. CSS: activity-event-row, error-event-row. Config fix: Z.ai API key.
This commit is contained in:
Rose
2026-04-20 13:28:37 +02:00
parent 96977b576a
commit fbf79362a4
6 changed files with 1652 additions and 104 deletions

View File

@@ -58,7 +58,7 @@ from api import agents as _agents
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
def _normalize_host_port(value: str) -> tuple[str, str | None]:
"""Split a host or host:port string into (hostname, port|None).
@@ -471,6 +471,14 @@ def handle_get(handler, parsed) -> bool:
settings.pop("password_hash", None)
return j(handler, settings)
# ── Logs ──
if parsed.path == "/api/logs":
return _handle_logs_list(handler)
if parsed.path.startswith("/api/logs/"):
log_name = parsed.path[len("/api/logs/"):]
return _handle_logs_read(handler, log_name)
if parsed.path == "/api/onboarding/status":
return j(handler, get_onboarding_status())
@@ -768,8 +776,7 @@ def handle_get(handler, parsed) -> bool:
return j(handler, {"tasks": _mc.get_tasks()})
if parsed.path == "/api/mc/feed":
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, {"feed": _mc.get_feed(limit=limit)})
return j(handler, {"feed": _mc.get_feed(limit=50)})
# ── Agents API (Rose + Tier-2) ──
if parsed.path == "/api/agents":
@@ -783,6 +790,75 @@ def handle_get(handler, parsed) -> bool:
agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent_config(agent_id))
# GET /api/agents/{id} — full agent detail
if parsed.path == "/api/agents/rose" or parsed.path == "/api/agents/lotus" or \
parsed.path == "/api/agents/sunflower" or parsed.path == "/api/agents/forget-me-not" or \
parsed.path == "/api/agents/root" or parsed.path == "/api/agents/dandelion" or \
parsed.path == "/api/agents/iris" or parsed.path == "/api/agents/ivy":
agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent(agent_id))
# GET /api/agents/{id}/status
if parsed.path.startswith("/api/agents/") and parsed.path.endswith("/status"):
agent_id = parsed.path.split("/")[-2]
return j(handler, _agents.get_agent_status(agent_id))
# PUT /api/agents/{id}/soul
if parsed.path.endswith("/soul") and method == "PUT":
agent_id = parsed.path.split("/")[-2]
data = read_body(handler)
return j(handler, _agents.update_agent_soul(agent_id, data.get("content", "")))
# PUT /api/agents/{id}/memory
if parsed.path.endswith("/memory") and method == "PUT":
agent_id = parsed.path.split("/")[-2]
data = read_body(handler)
return j(handler, _agents.update_agent_memory(agent_id, data.get("content", "")))
# POST /api/agents/{id}/message
if parsed.path.endswith("/message") and method == "POST":
agent_id = parsed.path.split("/")[-2]
data = read_body(handler)
return j(handler, _agents.send_agent_message(agent_id, data))
# POST /api/agents/{id}/ack/{msg_id}
if "/ack/" in parsed.path and method == "POST":
parts = parsed.path.split("/")
agent_id = parts[2]
msg_id = parts[4]
return j(handler, _agents.ack_agent_message(agent_id, msg_id))
# POST /api/agents/{id}/enable | /disable
if parsed.path.endswith("/enable") or parsed.path.endswith("/disable"):
if method == "POST":
agent_id = parsed.path.split("/")[-2]
action = parsed.path.split("/")[-1]
return j(handler, _agents.set_agent_enabled(agent_id, action == "enable"))
# GET /api/agents/{id}/inbox (full, with limit query param)
if parsed.path.startswith("/api/agents/") and "/inbox" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "inbox":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, _agents.get_agent_inbox(agent_id, limit=limit))
# GET /api/agents/{id}/activity
if parsed.path.startswith("/api/agents/") and "/activity" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "activity":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, _agents.get_agent_activity(agent_id, limit=limit))
# GET /api/agents/{id}/errors
if parsed.path.startswith("/api/agents/") and "/errors" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "errors":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
return j(handler, _agents.get_agent_errors(agent_id, limit=limit))
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
from api.profiles import list_profiles_api, get_active_profile_name
@@ -1248,6 +1324,14 @@ def handle_post(handler, parsed) -> bool:
except RuntimeError as e:
return bad(handler, str(e), 409)
# ── Logs API ──
if parsed.path == "/api/logs":
return _handle_logs_list(handler)
if parsed.path.startswith("/api/logs/"):
log_name = parsed.path[len("/api/logs/"):]
return _handle_logs_read(handler, log_name)
# ── Gateway API ──
if parsed.path == "/api/gateways":
# GET - list all gateways
@@ -3158,3 +3242,89 @@ def _handle_session_import(handler, body):
SESSIONS.popitem(last=False)
s.save()
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
# ── Logs ──────────────────────────────────────────────────────────────────
ALLOWED_LOG_FILES = {
"agent.log": "~/.hermes/logs/agent.log",
"errors.log": "~/.hermes/logs/errors.log",
"gateway.log": "~/.hermes/logs/gateway.log",
"gateway.error.log": "~/.hermes/logs/gateway.error.log",
"update.log": "~/.hermes/logs/update.log",
"webui.log": "~/.hermes/logs/webui.log",
"webui-prod.log": "~/.hermes/webui/bootstrap-8787.log",
"webui-dev.log": "~/.hermes/webui-dev/bootstrap-8788.log",
}
def _handle_logs_list(handler):
"""Return list of available log files with metadata."""
logs = []
for name, rel_path in ALLOWED_LOG_FILES.items():
path = Path(rel_path.replace("~", str(Path.home())))
if path.exists():
stat = path.stat()
logs.append({
"name": name,
"path": str(path),
"size": stat.st_size,
"modified": stat.st_mtime,
"size_human": _human_size(stat.st_size),
})
else:
logs.append({
"name": name,
"path": str(path),
"size": 0,
"modified": None,
"size_human": "0 B",
"missing": True,
})
return j(handler, {"logs": logs})
def _human_size(num_bytes):
for unit in ["B", "KB", "MB", "GB"]:
if num_bytes < 1024:
return f"{num_bytes:.1f} {unit}"
num_bytes /= 1024
return f"{num_bytes:.1f} TB"
def _handle_logs_read(handler, log_name):
"""Return last N lines of a log file."""
if log_name not in ALLOWED_LOG_FILES:
return bad(handler, f"Unknown log file: {log_name}")
rel_path = ALLOWED_LOG_FILES[log_name]
path = Path(rel_path.replace("~", str(Path.home())))
# Security: resolve and verify path stays within ~/.hermes
try:
resolved = path.resolve()
hermes_root = Path.home() / ".hermes"
if not str(resolved).startswith(str(hermes_root)):
return bad(handler, "Access denied")
except Exception:
return bad(handler, "Invalid path")
if not path.exists():
return bad(handler, f"Log file not found: {log_name}")
# Tail last 1000 lines
try:
lines = path.read_text(errors="replace").splitlines()
tail = lines[-1000:]
content = "\n".join(tail)
except Exception as e:
return bad(handler, f"Cannot read log: {e}")
stat = path.stat()
return j(handler, {
"name": log_name,
"content": content,
"size": stat.st_size,
"size_human": _human_size(stat.st_size),
"line_count": len(lines),
"tail_count": len(tail),
})