441 lines
No EOL
20 KiB
Python
441 lines
No EOL
20 KiB
Python
"""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() |