feat(operations): פאנל בריאות-הסוכנים + הסלמות אחרונות ב-/operations (#218+#222 UI)
הטמעת מוקאפ 02e (מאושר Claude-Design X17) — נראות שרת-הצד שנבנתה כ-backend: - backend: get_recent_escalations() ב-paperclip_client — משחזר הסלמות מהערות author_type='system' (severity+reason מנותחים מהגוף, identifier מ-join); Port pc_get_recent_escalations (read-only); endpoint GET /api/operations/agents/escalations. - frontend: useAgentHealth + useRecentEscalations (טיפוסים מקומיים כמו שאר operations.ts) + AgentHealthPanel ב-/operations תחת "בריאות-הסוכנים": 4 stat-tiles (זומבי/תקוע/עובד/רגוע), שורות-בעיה worst-first, וקארד הסלמות-אוטומטיות-אחרונות (severity+תיק+סיבה+מתי). דיוק מול המוקאפ: issue מוסלם עובר לבעלות-חיים→יוצא מרשימת-הבריאות, לכן שורות-הבריאות = זומבים טרם-הסלמה וקארד-ההסלמות = מטופלים (משלימים). Invariants: G12 (fetch מהמעטפת, המגע מהשער; UI צורך endpoint דומייני), G2 (מקור-בריאות/הסלמות יחיד). שער-עיצוב: מוקאפ 02e אושר לפני קוד (feedback_claude_design_gate). בדיקות: 13 pytest (2 חדשות ל-get_recent_escalations: parse severity/reason, fallback). frontend: tsc --noEmit + eslint נקיים (build מלא ב-CI/deploy — symlink node_modules שובר next build ב-worktree). api:types לא נדרש (operations.ts טיפוסים ידניים); ה-endpoint נפרס אטומית עם ה-UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ from web.paperclip_client import (
|
||||
create_project as pc_create_project,
|
||||
create_workflow_issue as pc_create_workflow_issue,
|
||||
get_agent_health as pc_get_agent_health,
|
||||
get_recent_escalations as pc_get_recent_escalations,
|
||||
get_predecessor_context as pc_get_predecessor_context,
|
||||
get_agents_for_case as pc_get_agents_for_case,
|
||||
get_agents_for_company as pc_get_agents,
|
||||
@@ -180,6 +181,7 @@ __all__ = [
|
||||
"pc_escalate_issue",
|
||||
# agent-run observability + control (live view + smart management)
|
||||
"pc_get_agent_health",
|
||||
"pc_get_recent_escalations",
|
||||
"pc_get_predecessor_context",
|
||||
"pc_list_live_runs",
|
||||
"pc_get_run_log",
|
||||
|
||||
10
web/app.py
10
web/app.py
@@ -63,6 +63,7 @@ from web.agent_platform_port import (
|
||||
pc_create_workflow_issue,
|
||||
pc_escalate_issue,
|
||||
pc_get_agent_health,
|
||||
pc_get_recent_escalations,
|
||||
pc_get_predecessor_context,
|
||||
pc_get_agents,
|
||||
pc_get_agents_for_case,
|
||||
@@ -7593,6 +7594,15 @@ async def operations_agent_health():
|
||||
return await pc_get_agent_health()
|
||||
|
||||
|
||||
@app.get("/api/operations/agents/escalations")
|
||||
async def operations_agent_escalations(limit: int = 10, hours: int = 24):
|
||||
"""Recent chair escalations — watchdog + manual (#218/#222).
|
||||
|
||||
Read-only history of what was handed to the chair, for the ops health panel.
|
||||
"""
|
||||
return await pc_get_recent_escalations(limit, hours)
|
||||
|
||||
|
||||
@app.get("/api/operations/issues/{issue_id}/predecessor")
|
||||
async def operations_issue_predecessor(issue_id: str, limit: int = 3):
|
||||
"""Recent finished runs on an issue for session continuation (#220, "seance").
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
@@ -1132,6 +1133,57 @@ async def get_agent_health() -> dict:
|
||||
return {"ok": True, "items": items, "counts": counts}
|
||||
|
||||
|
||||
# The escalation note escalate_issue() writes as an author_type='system' comment.
|
||||
# One prefix + a [severity] tag — parsed back here for the ops panel (#218/#222 UI).
|
||||
_ESCALATION_BODY_PREFIX = "🚨 הסלמה"
|
||||
_ESCALATION_SEVERITY_RE = re.compile(r"\[(critical|high|medium)\]")
|
||||
|
||||
|
||||
async def get_recent_escalations(limit: int = 10, hours: int = 24) -> dict:
|
||||
"""Recent chair escalations (watchdog + manual), newest-first (#218/#222 UI).
|
||||
|
||||
Read-only. Reconstructs escalations from the ``author_type='system'`` notes
|
||||
escalate_issue() leaves (``🚨 הסלמה [severity] לחיים\\n\\n<reason>``) — severity
|
||||
parsed from the tag, reason from the body, identifier joined from the issue.
|
||||
Scoped to the two legal-ai companies; ``limit`` clamped 1..50, ``hours`` 1..168.
|
||||
"""
|
||||
limit = max(1, min(int(limit), 50))
|
||||
hours = max(1, min(int(hours), 168))
|
||||
company_ids = list(COMPANIES.values())
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
f"""SELECT ic.issue_id, ic.body, ic.created_at, i.identifier
|
||||
FROM issue_comments ic
|
||||
JOIN issues i ON i.id = ic.issue_id
|
||||
WHERE ic.author_type = 'system'
|
||||
AND ic.company_id = ANY($1::uuid[])
|
||||
AND ic.body LIKE $2
|
||||
AND ic.deleted_at IS NULL
|
||||
AND ic.created_at > now() - interval '{hours} hours'
|
||||
ORDER BY ic.created_at DESC
|
||||
LIMIT $3""",
|
||||
company_ids, f"{_ESCALATION_BODY_PREFIX}%", limit,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
body = r["body"] or ""
|
||||
m = _ESCALATION_SEVERITY_RE.search(body)
|
||||
severity = m.group(1) if m else "medium"
|
||||
reason = body.split("\n\n", 1)[1].strip() if "\n\n" in body else body
|
||||
items.append({
|
||||
"issue_id": str(r["issue_id"]),
|
||||
"identifier": r["identifier"],
|
||||
"severity": severity,
|
||||
"reason": reason,
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
})
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
|
||||
async def get_predecessor_context(issue_id: str, limit: int = 3) -> dict:
|
||||
"""Recent finished runs on an issue, newest-first, for session continuation (#220).
|
||||
|
||||
|
||||
@@ -143,3 +143,58 @@ def test_get_agent_health_empty(monkeypatch):
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
result = asyncio.run(pc.get_agent_health())
|
||||
assert result == {"ok": True, "items": [], "counts": {"zombie": 0, "stalled": 0, "working": 0, "idle": 0}}
|
||||
|
||||
|
||||
# ── recent escalations (fake DB) — parses severity + reason from system notes ──
|
||||
class _FetchConn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
async def fetch(self, query, *args):
|
||||
return self._rows
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_get_recent_escalations_parses_severity_and_reason(monkeypatch):
|
||||
from datetime import datetime
|
||||
|
||||
rows = [
|
||||
{
|
||||
"issue_id": "iss-1",
|
||||
"identifier": "CMP-141",
|
||||
"body": "🚨 הסלמה [high] לחיים\n\nזוהתה לולאת-recovery — 4 יקיצות-שחזור; נמסר לחיים.",
|
||||
"created_at": datetime(2026, 7, 7, 3, 30, 0),
|
||||
},
|
||||
{
|
||||
"issue_id": "iss-2",
|
||||
"identifier": "CMPA-112",
|
||||
"body": "🚨 הסלמה [medium] לחיים\n\nזוהתה לולאת-recovery — 2 יקיצות.",
|
||||
"created_at": datetime(2026, 7, 7, 2, 49, 0),
|
||||
},
|
||||
]
|
||||
|
||||
async def _connect(_url):
|
||||
return _FetchConn(rows)
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
result = asyncio.run(pc.get_recent_escalations())
|
||||
assert result["ok"] is True
|
||||
a, b = result["items"]
|
||||
assert a["identifier"] == "CMP-141" and a["severity"] == "high"
|
||||
assert "4 יקיצות" in a["reason"] and "🚨" not in a["reason"]
|
||||
assert a["created_at"] == "2026-07-07T03:30:00"
|
||||
assert b["severity"] == "medium"
|
||||
|
||||
|
||||
def test_get_recent_escalations_defaults_severity_when_untagged(monkeypatch):
|
||||
rows = [{"issue_id": "x", "identifier": "CMP-9", "body": "🚨 הסלמה לחיים", "created_at": None}]
|
||||
|
||||
async def _connect(_url):
|
||||
return _FetchConn(rows)
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
(item,) = asyncio.run(pc.get_recent_escalations())["items"]
|
||||
assert item["severity"] == "medium" # fallback
|
||||
assert item["created_at"] is None
|
||||
|
||||
Reference in New Issue
Block a user