feat(operations): פאנל בריאות-הסוכנים + הסלמות אחרונות ב-/operations (#218+#222 UI)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 30s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

הטמעת מוקאפ 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:
2026-07-07 04:44:50 +00:00
parent a9db657541
commit cf7c918bed
6 changed files with 328 additions and 0 deletions

View File

@@ -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).