Auftragsbuch: Ketten-Sicht (Projekt-Gruppen) + Telegram-Ketten-Digest
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'. Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links + Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort, Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+116
-1
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -24,6 +25,12 @@ from pathlib import Path
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
|
||||
# Ketten-Sicht (20.07.): Eltern-Links + Heartbeat-Notizen stehen NICHT im `list --json`
|
||||
# der CLI — dafür (und NUR dafür) ein read-only-Blick in die kanban.db. Das bricht die
|
||||
# „alles über die CLI"-Regel bewusst minimal: zwei stabile Tabellen (task_links 2 Spalten,
|
||||
# task_events), Verbindung strikt mode=ro, jeder Fehler fällt lautlos auf die flache
|
||||
# Sicht zurück. Schreiben läuft weiterhin ausschließlich über die CLI.
|
||||
KANBAN_DB = Path(os.environ.get("MC_KANBAN_DB", "~/.hermes/kanban.db")).expanduser()
|
||||
|
||||
_TASK_ID_RX = re.compile(r"^t_[0-9a-f]{4,16}$")
|
||||
|
||||
@@ -92,6 +99,113 @@ def _blocker_frage(task_id: str) -> dict | None:
|
||||
return {"frage": frage[:800], "kind": kind}
|
||||
|
||||
|
||||
def _projekt_titel(titles: list[str]) -> str:
|
||||
"""Anzeigename einer Karten-Familie: gemeinsamer Titel-Präfix (z. B. „Homelab-Dashboard 2.0"),
|
||||
sonst der Titel der ältesten Karte. Reine Anzeige-Heuristik, nichts hängt davon ab."""
|
||||
if not titles:
|
||||
return "Projekt"
|
||||
prefix = titles[0]
|
||||
for t in titles[1:]:
|
||||
while prefix and not t.startswith(prefix):
|
||||
prefix = prefix[:-1]
|
||||
prefix = prefix.strip(" –-:·")
|
||||
return prefix if len(prefix) >= 10 else titles[0][:80]
|
||||
|
||||
|
||||
def _ketten_anreichern(items: list[dict]) -> list[dict]:
|
||||
"""Ketten-Sicht über die Queue legen (read-only, best-effort).
|
||||
|
||||
Ergänzt pro Karte: `eltern` (offene Vorgänger-IDs), `wartet_auf` (deren Titel),
|
||||
`notiz` (jüngste Heartbeat-Notiz des Workers bei running) und `projekt`
|
||||
(Familien-Schlüssel). Liefert die Projekt-Gruppen für die UI: zusammenhängende
|
||||
Karten (task_links) = ein Projekt; Einzelkarten bleiben ohne `projekt`.
|
||||
Jeder Fehler (Windows-Dev, Schema-Drift, Lock) → unveränderte flache Sicht.
|
||||
"""
|
||||
for it in items:
|
||||
it.setdefault("eltern", [])
|
||||
it.setdefault("wartet_auf", [])
|
||||
it.setdefault("notiz", None)
|
||||
it.setdefault("projekt", None)
|
||||
if not items or not KANBAN_DB.is_file():
|
||||
return []
|
||||
by_id = {it["id"]: it for it in items if it.get("id")}
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{KANBAN_DB}?mode=ro", uri=True, timeout=3)
|
||||
try:
|
||||
con.execute("PRAGMA busy_timeout=2000")
|
||||
links = [(p, c) for p, c in con.execute(
|
||||
"SELECT parent_id, child_id FROM task_links")
|
||||
if p in by_id or c in by_id]
|
||||
for it in items:
|
||||
if it.get("status") != "running":
|
||||
continue
|
||||
for (payload,) in con.execute(
|
||||
"SELECT payload FROM task_events WHERE task_id=? AND kind='heartbeat' "
|
||||
"AND payload IS NOT NULL ORDER BY created_at DESC LIMIT 5", (it["id"],)):
|
||||
try:
|
||||
note = (json.loads(payload) or {}).get("note")
|
||||
except Exception:
|
||||
note = None
|
||||
if note:
|
||||
it["notiz"] = str(note)[:300]
|
||||
break
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
log.debug("ideen: Ketten-Anreicherung fehlgeschlagen", exc_info=True)
|
||||
return []
|
||||
|
||||
# Eltern/wartet_auf: nur Vorgänger zählen, die selbst noch offen in der Liste stehen —
|
||||
# archivierte/fremde Parents gelten als erledigt.
|
||||
ERLEDIGT = ("done",)
|
||||
for parent, child in links:
|
||||
it = by_id.get(child)
|
||||
p = by_id.get(parent)
|
||||
if it and p and p.get("status") not in ERLEDIGT:
|
||||
it["eltern"].append(parent)
|
||||
if len(it["wartet_auf"]) < 3:
|
||||
it["wartet_auf"].append((p.get("titel") or parent)[:90])
|
||||
|
||||
# Familien = zusammenhängende Komponenten über die Links (Union-Find, klein genug).
|
||||
chef: dict[str, str] = {}
|
||||
|
||||
def boss(x: str) -> str:
|
||||
while chef.get(x, x) != x:
|
||||
chef[x] = chef.get(chef[x], chef[x])
|
||||
x = chef[x]
|
||||
return x
|
||||
|
||||
for parent, child in links:
|
||||
if parent in by_id and child in by_id:
|
||||
a, b = boss(parent), boss(child)
|
||||
if a != b:
|
||||
chef[a] = b
|
||||
|
||||
familien: dict[str, list[dict]] = {}
|
||||
for it in items:
|
||||
familien.setdefault(boss(it["id"]), []).append(it)
|
||||
projekte = []
|
||||
for wurzel, mitglieder in familien.items():
|
||||
if len(mitglieder) < 2:
|
||||
continue
|
||||
mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
|
||||
key = mitglieder[0]["id"]
|
||||
for it in mitglieder:
|
||||
it["projekt"] = key
|
||||
fertig = sum(1 for i in mitglieder if i["status"] in ERLEDIGT)
|
||||
projekte.append({
|
||||
"key": key,
|
||||
"titel": _projekt_titel([i["titel"] for i in mitglieder]),
|
||||
"gesamt": len(mitglieder),
|
||||
"fertig": fertig,
|
||||
"laeuft": sum(1 for i in mitglieder if i["status"] == "running"),
|
||||
"haengt": sum(1 for i in mitglieder if i["status"] == "blocked"),
|
||||
})
|
||||
# Aktive Projekte zuerst (hängend > laufend > Rest), dann nach Größe.
|
||||
projekte.sort(key=lambda p: (-p["haengt"], -p["laeuft"], -p["gesamt"]))
|
||||
return projekte
|
||||
|
||||
|
||||
def list_queue() -> dict:
|
||||
"""Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI."""
|
||||
if not _available():
|
||||
@@ -137,8 +251,9 @@ def list_queue() -> dict:
|
||||
it["frage"] = info["frage"]
|
||||
it["frage_kind"] = info["kind"]
|
||||
|
||||
projekte = _ketten_anreichern(items)
|
||||
offen = sum(1 for i in items if i["status"] not in ("done",))
|
||||
data = {"available": True, "items": items, "offen": offen}
|
||||
data = {"available": True, "items": items, "offen": offen, "projekte": projekte}
|
||||
_list_cache.update(ts=now, data=data)
|
||||
return data
|
||||
|
||||
|
||||
Reference in New Issue
Block a user