simbiot-harness/harness/dashboard.py

441 lines
No EOL
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Simbiot Harness — дашборд задач: веб-страница + JSON-API.
Задача t-260929164722-60dae2 [M]: страница со всеми задачами харнеса
(статусы/размеры/уровни), автообновление. Плюс лестница уровней,
эскалации и свежие дайджесты — состояние харнеса одним взглядом.
Дизайн: stdlib-only (http.server + ThreadingHTTPServer), никаких
зависимостей и лишних процессов. Читает событие-стор напрямую через
run_projections() (та же проекция, что у scheduler/CLI — консистентно).
Порты: 8765 (dashboard) / 8766 (healthcheck, без авторизации),
SIMBIOT_DASH_PORT/SIMBIOT_DASH_ADDR/SIMBIOT_DASH_TOKEN — из env.
Открытый мир — токен SIMBIOT_DASH_TOKEN (query ?token= или cookie):
страница и API без токена отдают 401 (кроме порта 8766 — там только
"ok", без данных; healthcheck для systemd не должен падать на auth).
"""
import json
import os
import sys
import threading
import time
from http import cookies
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import commands, es # noqa: E402
_HERE = os.path.dirname(os.path.abspath(__file__))
_INDEX = os.path.join(_HERE, "dashboard.html")
_ADDR = os.environ.get("SIMBIOT_DASH_ADDR", "0.0.0.0")
_PORT = int(os.environ.get("SIMBIOT_DASH_PORT", "8765"))
_HEALTH_PORT = int(os.environ.get("SIMBIOT_DASH_HEALTH_PORT", "8766"))
_TOKEN = os.environ.get("SIMBIOT_DASH_TOKEN", "")
_REFRESH = int(os.environ.get("SIMBIOT_DASH_REFRESH", "5"))
_STATUS_ORDER = {"ready": 0, "running": 1, "failed": 2, "done": 3}
_SIZE_COLOR = {"S": "#1d7a3e", "M": "#a3690f", "L": "#FF4F00", "XL": "#b3261e"}
# ── чат Simbiot прямо в дашборде (t-261001100740-99d55e) ──────────────────
# Канал один для всех UI: БД портала agent_portal_chat (/root/coop/coop.db),
# вход — sender='user' (пишет портал/дашборд), ответ агента — sender='agent'
# (HTTP-push из сек-рефлекса portal_channel.push). Реплаи из стора харнеса
# (ReplySent) — дубль текста ответа, берём из проекции инбокса (канал «portal»,
# у master-хода там же ходим TG-реплаи — их chat_id не 'portal:*', не мешаем).
import sqlite3 as _sqlite3
_COOP_DB = "/root/coop/coop.db"
_COOP_AGENT_CACHE = {"id": None, "name": None, "ts": 0.0}
_CHAT_MAX = 8000 # ровно кап coop-портала в chat/send
def _coop_agent(ttl: float = 60.0):
"""Агент Simbiot в БД портала (id нужен для адресации agent_portal_chat)."""
now = time.time()
if _COOP_AGENT_CACHE["id"] and now - _COOP_AGENT_CACHE["ts"] < ttl:
return _COOP_AGENT_CACHE["id"], _COOP_AGENT_CACHE["name"]
try:
db = _sqlite3.connect(_COOP_DB)
db.row_factory = _sqlite3.Row
row = db.execute(
"SELECT id, agent_name FROM agents WHERE harness_type='simbiot' "
"ORDER BY (agent_name='Simbiot') DESC, id ASC LIMIT 1").fetchone()
db.close()
except Exception:
return None, None
if not row:
return None, None
_COOP_AGENT_CACHE.update({"id": int(row["id"]), "name": row["agent_name"],
"ts": now})
return _COOP_AGENT_CACHE["id"], _COOP_AGENT_CACHE["name"]
def _ts_to_ms(ts) -> int:
"""ISO-строка стора или int-ms → int-ms (единая сортировка чата)."""
if isinstance(ts, (int, float)):
return int(ts)
from datetime import datetime
try:
return int(datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return 0
def build_chat(limit: int = 80) -> dict:
"""Лента чата: БД портала (primary) + реплаи стора (полнота текста батчей).
Дедуп: вход пользователя в обоих источниках под одним ключом
('m-'+msg_id) — версию стора предпочитаем (там replied/reply_text).
"""
agent_id, agent_name = _coop_agent()
if not agent_id:
return {"ok": False, "error": "no_agent", "messages": []}
db = _sqlite3.connect(_COOP_DB)
db.row_factory = _sqlite3.Row
rows = db.execute(
"SELECT msg_id, sender, text, ts, consumed FROM agent_portal_chat "
"WHERE agent_id=? ORDER BY ts DESC LIMIT ?", (agent_id, limit)).fetchall()
db.close()
store = {}
try:
model = es.run_projections()
for m in (model.get("inbox") or {}).values():
if isinstance(m, dict) and str(m.get("chat_id") or "").startswith("portal"):
m = dict(m)
m["store_id"] = "m-" + str(m.get("update_id") or "")
m["ts_ms"] = _ts_to_ms(m.get("ts") or m.get("date"))
store[m["store_id"]] = m
except Exception:
pass # стор недоступен: БД портала всё равно покажет ленту
msgs = []
# анкеры: msg_id agent-реплаев вида "reply-<ms>-<update_id>" — покрывает
# push-строку в БД портала; реплай стора дублирует её — не показываем
anchors = set()
for r in rows:
if r["sender"] == "agent" and str(r["msg_id"]).startswith("reply-"):
anchors.add("-".join(str(r["msg_id"]).split("-", 2)[2:]))
for r in reversed(rows): # старые → новые
# служебные команды канала (/set-tg-bot с токеном, cmd-settg-*) — не
# чата: токен секрет, в ленту UI не показываем (t-261001100740)
if (r["sender"] == "user" and (str(r["msg_id"]).startswith("cmd-settg-")
or str(r["text"]).startswith("/set-tg-bot"))):
continue
if r["sender"] == "agent" and str(r["text"]).lstrip().startswith("/set-tg"):
continue
key = "m-" + str(r["msg_id"])
st = store.get(key) if r["sender"] == "user" else None
if st is not None and st.get("replied") and st.get("reply_text") \
and str(st.get("update_id") or "") not in anchors:
# реплай стора НЕ отражён в БД портала — показываем ПАРУ:
# вопрос пользователя + ответ агента из стора
msgs.append({
"id": str(r["msg_id"]), "from": "user",
"text": st.get("text") or r["text"],
"ts": int(r["ts"] or 0), "channel": "portal", "pending": False,
})
msgs.append({
"id": str(r["msg_id"]) + "#reply", "from": "agent",
"text": st.get("reply_text"),
"ts": _ts_to_ms(st.get("ts") or r["ts"]) or int(r["ts"] or 0),
"channel": "portal", "pending": False,
})
continue
if st is not None:
text = st.get("text") or r["text"]
pending = not (st.get("replied") or r["consumed"])
else:
text = r["text"]
pending = bool(r["sender"] == "user" and not r["consumed"] and
time.time() * 1000 - int(r["ts"] or 0) < 600_000)
msgs.append({
"id": str(r["msg_id"]),
"from": r["sender"],
"text": text,
"ts": int(r["ts"] or 0),
"channel": "portal",
"pending": bool(pending),
})
# последние реплаи стора, которых ещё нет в БД (push идёт напрямую);
# их нет как строк БД — показываем только если ретрай не доехал до портала
seen_db_ids = {str(r["msg_id"]) for r in rows}
for st in store.values():
if (str(st.get("update_id") or "") not in seen_db_ids
and st.get("replied") and st.get("reply_text")):
msgs.append({"id": st["store_id"], "from": "agent",
"text": st.get("reply_text"), "ts": st.get("ts_ms") or 0,
"channel": "portal", "pending": False})
msgs.sort(key=lambda m: m["ts"])
# схлопнуть подряд идущие ОДИНАКОВЫЕ agent-пуши (ретраи ретраят с новым
# msg_id — settg-диагностика светится как шум ×3): keep-last + счётчик
collapsed = []
for m in msgs:
if (m["from"] == "agent" and collapsed
and collapsed[-1]["from"] == "agent"
and collapsed[-1].get("text") == m.get("text")
and int(m["ts"]) - int(collapsed[-1]["ts"]) < 600_000):
collapsed[-1]["repeat"] = collapsed[-1].get("repeat", 1) + 1
collapsed[-1]["ts"] = m["ts"]
continue
collapsed.append(m)
return {"ok": True, "agent": agent_name, "messages": collapsed[-limit:]}
def chat_send(text: str) -> dict:
"""Сообщение владельца агенту: прямая запись в agent_portal_chat.
HTTP-роут портала требует юзер-сессию, дашборд авторизован своим токеном —
пишем напрямую в ту же таблицу (sender='user', consumed=0): сек-рефлекс
харнеса поднимет её ≤20 с и мин-LLM ответит в тот же канал.
"""
text = (text or "").strip()
if not text:
return {"ok": False, "error": "empty"}
agent_id, _ = _coop_agent()
if not agent_id:
return {"ok": False, "error": "no_agent"}
mid = f"dash-{agent_id}-{int(time.time() * 1000)}-{os.urandom(3).hex()}"
db = _sqlite3.connect(_COOP_DB, timeout=10)
try:
db.execute(
"INSERT INTO agent_portal_chat (msg_id, agent_id, sender, text, ts) "
"VALUES (?,?,?,?,?)", (mid, agent_id, "user", text[:_CHAT_MAX],
int(time.time() * 1000)))
db.commit()
finally:
db.close()
return {"ok": True, "msg_id": mid}
def _utcnow() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
def build_state() -> dict:
"""Срез харнеса для API: задачи, лестница, эскалации, дайджесты, спенд.
Полный run_projections() на каждый запрос — безопасно: инкрементальный
чекпоинт в БД, работа пропорциональна дельте событий, не истории.
"""
model = es.run_projections()
# отчёт тика, закрывшего задачу: digest с summary_ref == result_ref
digest_by_ref = {}
for d in (model.get("digests") or {}).values():
if d.get("summary_ref"):
digest_by_ref[d["summary_ref"]] = d
tasks = []
for t in model.get("tasks", {}).values():
report = digest_by_ref.get(t.get("result_ref")) or {}
tasks.append({
"id": t.get("id"), "title": t.get("title", ""),
"size": t.get("size", "?"), "status": t.get("status", "?"),
"level": t.get("level"), "created": t.get("created"),
"finished": t.get("finished"), "error": t.get("error"),
# детали для раскрытия по клику (т-260929220004)
"details": (t.get("details") or "")[:2000],
"promotions": t.get("promotions"), "promoted_to": t.get("promoted_to"),
"session_id": t.get("session_id"),
# t-261001082111: время взятия в работу (TaskClaimed) — для
# пометки «в работе» и таймера elapsed на карточке running
"claimed_at": t.get("claimed_at"),
"report_level": report.get("level"),
"report_ts": report.get("ts"),
"report": (report.get("text") or "")[:1500],
})
# два стабильных прохода: новые сверху внутри статуса,
# статусы по порядку ready→running→failed→done
tasks.sort(key=lambda x: x.get("created") or "", reverse=True)
tasks.sort(key=lambda x: _STATUS_ORDER.get(x["status"], 9))
ladder = []
for name, cs in sorted(model.get("cycle_state", {}).items()):
ladder.append({
"level": name, "busy": bool(cs.get("busy")),
"paused": bool(cs.get("paused")),
"ticks": cs.get("ticks", 0), "absorbed": cs.get("absorbed", 0),
"errors": cs.get("errors", 0), "last_tick": cs.get("last_tick"),
"last_error": cs.get("last_error"),
})
escalations = [e for e in (model.get("escalations") or {}).values()
if e.get("status") == "open"]
escalations.sort(key=lambda e: e.get("ts", ""), reverse=True)
digests = []
for d in model.get("digests", {}).values():
digests.append({
"level": d.get("level"), "period": d.get("period"),
"text": d.get("text", ""), "ts": d.get("ts"),
"summary_ref": d.get("summary_ref"),
})
digests.sort(key=lambda d: d.get("ts") or "", reverse=True)
spend = model.get("spend", [])[-12:]
counts = {}
for t in tasks:
key = f"{t['status']}:{t['size']}"
counts[key] = counts.get(key, 0) + 1
return {
"generated_at": _utcnow(),
"counts": {
"tasks_total": len(tasks),
"tasks_by_status": {
s: sum(1 for t in tasks if t["status"] == s)
for s in ("ready", "running", "failed", "done")
},
"tasks_by_status_size": counts,
"escalations_open": len(escalations),
"events_total": es.count_events(),
"digests_total": len(model.get("digests", {})),
},
"ladder": ladder,
"tasks": tasks,
"escalations": escalations,
"digests": digests[:12],
"spend": spend,
"meta": {"refresh_seconds": _REFRESH, "token_required": bool(_TOKEN)},
}
class _Handler(BaseHTTPRequestHandler):
server_version = "SimbiotDash/1.0"
def log_message(self, fmt, *args): # тише в journalctl: одна строка
print(f"[dash] {self.address_string()} {fmt % args}", flush=True)
# ── auth ──
def _authorized(self) -> bool:
if not _TOKEN:
return True
supplied = None
qs = self.path.partition("?")[2]
from urllib.parse import parse_qs
for v in parse_qs(qs).get("token", []):
supplied = v
if supplied is None:
c = cookies.SimpleCookie(self.headers.get("Cookie", ""))
if "simbiot_token" in c:
supplied = c["simbiot_token"].value
return supplied == _TOKEN
def _forbidden(self):
body = b'{"error": "unauthorized: pass ?token=SIMBIOT_DASH_TOKEN"}\n'
self.send_response(401)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("WWW-Authenticate", 'Bearer realm="simbiot-dash"')
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, obj, code=200):
body = json.dumps(obj, ensure_ascii=False, default=str).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, text: str):
body = text.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# ── routes ──
def do_GET(self):
route = self.path.partition("?")[0]
if route == "/api/state":
if not self._authorized():
return self._forbidden()
try:
return self._json(build_state())
except Exception as e: # стор бит/занят — 503, фронт покажет retry
return self._json({"error": str(e)[:500]}, code=503)
if route == "/api/chat":
# чат Simbiot в дашборде (t-261001100740): read — авторизуем так же
if not self._authorized():
return self._forbidden()
try:
return self._json(build_chat())
except Exception as e:
return self._json({"ok": False, "error": str(e)[:500],
"messages": []}, code=503)
if route == "/api/health":
return self._json({"ok": True, "ts": _utcnow()})
if route in ("/", "/index.html"):
if not self._authorized():
return self._forbidden()
try:
with open(_INDEX, encoding="utf-8") as f:
return self._html(f.read())
except OSError as e:
return self._json({"error": f"dashboard.html not found: {e}"}, code=500)
return self._json({"error": "not found"}, code=404)
def do_POST(self):
route = self.path.partition("?")[0]
if route == "/api/chat/send":
# send чата (t-261001100740): та же авторизация, что у read-API
if not self._authorized():
return self._forbidden()
try:
n = int(self.headers.get("Content-Length") or 0)
except ValueError:
n = 0
if n <= 0 or n > 64_000:
return self._json({"ok": False, "error": "bad length"}, code=400)
try:
data = json.loads(self.rfile.read(n).decode("utf-8"))
except Exception:
return self._json({"ok": False, "error": "bad json"}, code=400)
try:
return self._json(chat_send(str(data.get("text") or "")))
except Exception as e:
return self._json({"ok": False, "error": str(e)[:500]}, code=503)
return self._json({"error": "not found"}, code=404)
def _health_server():
"""Отдельный мини-порт для healthcheck: без auth, только {ok:true}."""
class H(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def do_GET(self):
body = b'{"ok":true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
srv = ThreadingHTTPServer(("127.0.0.1", _HEALTH_PORT), H)
srv.serve_forever()
def main():
es.init_db() # идемпотентно
if not os.path.exists(_INDEX):
print(f"[dash] FATAL: {_INDEX} не найден", file=sys.stderr)
sys.exit(1)
threading.Thread(target=_health_server, daemon=True).start()
print(f"[dash] http://{_ADDR}:{_PORT}/ (health 127.0.0.1:{_HEALTH_PORT}/api/health) "
f"refresh={_REFRESH}s token={'on' if _TOKEN else 'off'}", flush=True)
ThreadingHTTPServer((_ADDR, _PORT), _Handler).serve_forever()
if __name__ == "__main__":
main()