639 lines
30 KiB
Python
639 lines
30 KiB
Python
"""Hugin-Anbindung (→ Konzept 7.9): Pakete wählen, auflösen, festschreiben und für Runs laden.
|
|
|
|
Ablauf: Der Plattform-Admin trägt den Hugin-Host ein und sieht, welche Workspaces Hugin nutzen. Der
|
|
Workspace-Admin (ein Workspace gehört einem Team) trägt den Integrations-Key seines Tenants ein und wählt Pakete
|
|
(wie Zeilen in .hugin.yaml); „Abgleichen“ zeigt, was Hugin liefern würde, „Übernehmen“ schreibt Versionen
|
|
mit Prüfsumme fest und speichert jedes Paket im Quellformat. Neue Versionen kommen nur durch Übernehmen;
|
|
Pflichtpakete aus Hugin kommen immer mit. Für Runs werden nur festgeschriebene, geprüfte Kopien geladen.
|
|
|
|
Abbildung in der ersten Stufe: rules → System-Prompt, skill → Name und Beschreibung im Prompt, Inhalt über
|
|
load_skill und read_skill_file, command → Slash-Befehl im Chat. Nicht angeboten werden andere Arten, Pakete
|
|
mit Risiko scripts oder permissions und Befehle mit Shell-Einbettung. Hugin-Inhalte verleihen keine Rechte.
|
|
"""
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import logging
|
|
import re
|
|
import shlex
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import timedelta
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
|
|
import yaml
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app.core.config import Settings
|
|
from app.core.crypto import Vault
|
|
from app.core.database import now
|
|
from app.core.errors import Invalid, NotFound
|
|
from app.data_sources import service as sources
|
|
from app.data_sources.connectors.git_repo import GitError, RepoCache
|
|
from app.data_sources.models import DataSource
|
|
from app.hugin.client import Client, HuginError, ready
|
|
from app.hugin.models import HuginHost, WorkspaceHugin, WorkspaceHuginLock, WorkspaceHuginPackage
|
|
from app.identity import Principal
|
|
from app.identity.admin import require as require_platform_admin
|
|
from app.identity.models import Team, User
|
|
from app.mcp import service as mcp
|
|
from app.security import untrusted
|
|
from app.workspaces import service as workspaces
|
|
from app.workspaces.models import Workspace, WorkspaceMembership
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
SPEC = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*(?:@(?:stable|test|\^\d+|\d+\.\d+\.\d+))?$")
|
|
CONTENT_FILE = {"rules": "RULES.md", "skill": "SKILL.md", "command": "COMMAND.md"}
|
|
KIND_NAMES = {"rules": "Regeln", "skill": "Skill", "command": "Befehl", "subagent": "Subagent", "mcp": "MCP",
|
|
"plugin": "Plugin", "hook": "Hook"}
|
|
ALLOWED_RISKS = ("text", "tools")
|
|
COMMAND = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?$", re.S)
|
|
MAX_RULES_CHARS = 20000
|
|
# Beim Laden: Größe wurde beim Übernehmen geprüft; hier nur noch Schutz vor Unfug
|
|
LOAD_LIMIT = 64 << 20
|
|
|
|
|
|
# --- Host der Plattform (Plattform-Admin) ---
|
|
|
|
def host(session: Session) -> HuginHost | None:
|
|
return session.get(HuginHost, 1)
|
|
|
|
|
|
def save_host(session: Session, principal: Principal, settings: Settings, url: str) -> HuginHost:
|
|
require_platform_admin(principal)
|
|
url = mcp.check_url(url, settings).rstrip("/")
|
|
row = session.get(HuginHost, 1)
|
|
if row is None:
|
|
row = HuginHost(id=1, updated_by=principal.user_id)
|
|
session.add(row)
|
|
if row.url != url:
|
|
row.status, row.status_message, row.checked = "new", "", None
|
|
row.url, row.updated_by, row.updated = url, principal.user_id, now()
|
|
audit.record(session, "hugin.host_saved", user_id=principal.user_id, target_kind="hugin_host", target_id=1,
|
|
url=url)
|
|
return row
|
|
|
|
|
|
def check_host(session: Session, principal: Principal, settings: Settings) -> HuginHost:
|
|
"""Erreichbarkeit über /readyz; ob ein Key gilt, prüft jeder Workspace mit seinem Key."""
|
|
require_platform_admin(principal)
|
|
row = session.get(HuginHost, 1)
|
|
if row is None:
|
|
raise NotFound
|
|
error = ready(row.url, settings.hugin_timeout_seconds)
|
|
row.status, row.status_message = ("error", error) if error else ("ok", "erreichbar")
|
|
row.checked = now()
|
|
return row
|
|
|
|
|
|
@dataclass
|
|
class WorkspaceStatus:
|
|
"""Für die Übersicht des Plattform-Admins: ob und wie ein Workspace Hugin nutzt, ohne Key."""
|
|
workspace: Workspace
|
|
team: str
|
|
access: WorkspaceHugin | None
|
|
chosen: int
|
|
locked: int
|
|
|
|
@property
|
|
def findings(self) -> list[str]:
|
|
return describe(self.access.findings or {}) if self.access else []
|
|
|
|
|
|
def overview(session: Session) -> list[WorkspaceStatus]:
|
|
accesses = {row.workspace_id: row for row in session.scalars(select(WorkspaceHugin))}
|
|
chosen = dict(session.execute(select(WorkspaceHuginPackage.workspace_id, func.count())
|
|
.group_by(WorkspaceHuginPackage.workspace_id)).all())
|
|
locked = dict(session.execute(select(WorkspaceHuginLock.workspace_id, func.count())
|
|
.group_by(WorkspaceHuginLock.workspace_id)).all())
|
|
rows = session.execute(select(Workspace, Team.name).join(Team, Team.id == Workspace.team_id)
|
|
.where(Workspace.deleted.is_(None)).order_by(Team.name, Workspace.name)).all()
|
|
return [WorkspaceStatus(w, team, accesses.get(w.id), chosen.get(w.id, 0), locked.get(w.id, 0))
|
|
for w, team in rows]
|
|
|
|
|
|
# --- Zugang je Workspace (Workspace-Admin) ---
|
|
|
|
def access(session: Session, workspace_id: int) -> WorkspaceHugin | None:
|
|
return session.get(WorkspaceHugin, workspace_id)
|
|
|
|
|
|
def save_access(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int,
|
|
key: str) -> WorkspaceHugin:
|
|
"""Integrations-Key speichern und gleich prüfen; Tenant und Key-Name meldet Hugin."""
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
key = key.strip()
|
|
if not re.fullmatch(r"hi_[A-Za-z0-9_-]{16,200}", key):
|
|
raise Invalid("Das sieht nicht nach einem Integrations-Key von Hugin aus (hi_…).")
|
|
row = session.get(WorkspaceHugin, workspace_id)
|
|
if row is None:
|
|
row = WorkspaceHugin(workspace_id=workspace_id, tenant="", updated_by=principal.user_id)
|
|
session.add(row)
|
|
row.secret, row.hint, row.updated_by, row.updated = vault.encrypt(key), key[-4:], principal.user_id, now()
|
|
# Tenant bleibt bis zum Test stehen, damit ein Wechsel im Audit-Log erscheint
|
|
row.status, row.status_message, row.checked = "new", "", None
|
|
audit.record(session, "hugin.access_saved", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="workspace", target_id=workspace_id)
|
|
return test_access(session, principal, settings, vault, workspace_id)
|
|
|
|
|
|
def test_access(session: Session, principal: Principal, settings: Settings, vault: Vault,
|
|
workspace_id: int) -> WorkspaceHugin:
|
|
"""Key über /status prüfen; ein Wechsel des Tenants kommt ins Audit-Log (er bestimmt die Pflichtpakete)."""
|
|
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
|
|
row = session.get(WorkspaceHugin, workspace_id)
|
|
if row is None:
|
|
raise NotFound
|
|
try:
|
|
with client(session, settings, vault, workspace) as hugin:
|
|
info = hugin.status()
|
|
key = info.get("key") or {}
|
|
tenant = str(key.get("tenant", ""))[:64]
|
|
if tenant != row.tenant:
|
|
audit.record(session, "hugin.tenant_changed", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="workspace", target_id=workspace_id, before=row.tenant, after=tenant)
|
|
row.status, row.tenant, row.key_name = "ok", tenant, str(key.get("name", ""))[:200]
|
|
row.status_message = f"gültig bis {key['expires_at'][:10]}" if key.get("expires_at") else ""
|
|
except HuginError as e:
|
|
row.status, row.status_message = "error", str(e)
|
|
row.checked = now()
|
|
return row
|
|
|
|
|
|
def remove_access(session: Session, principal: Principal, workspace_id: int) -> None:
|
|
"""Festgeschriebene Pakete gelten weiter; Abgleichen und Rückmeldungen gehen nicht mehr."""
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
row = session.get(WorkspaceHugin, workspace_id)
|
|
if row is not None:
|
|
session.delete(row)
|
|
audit.record(session, "hugin.access_removed", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="workspace", target_id=workspace_id)
|
|
|
|
|
|
def client(session: Session, settings: Settings, vault: Vault, workspace: Workspace) -> Client:
|
|
instance = host(session)
|
|
if instance is None:
|
|
raise HuginError("Hugin ist auf dieser Plattform nicht eingerichtet (Administration → Hugin).")
|
|
row = access(session, workspace.id)
|
|
if row is None:
|
|
raise HuginError("Für diesen Workspace ist kein Hugin-Key eingetragen (Einstellungen → Hugin).")
|
|
return Client(instance.url, vault.decrypt(row.secret), settings.hugin_timeout_seconds)
|
|
|
|
|
|
# --- Auswahl je Workspace ---
|
|
|
|
def subscriptions(session: Session, workspace_id: int) -> list[WorkspaceHuginPackage]:
|
|
return list(session.scalars(select(WorkspaceHuginPackage).where(WorkspaceHuginPackage.workspace_id == workspace_id)
|
|
.order_by(WorkspaceHuginPackage.package_id)))
|
|
|
|
|
|
def locks(session: Session, workspace_id: int) -> list[WorkspaceHuginLock]:
|
|
return list(session.scalars(select(WorkspaceHuginLock).where(WorkspaceHuginLock.workspace_id == workspace_id)
|
|
.order_by(WorkspaceHuginLock.package_id)))
|
|
|
|
|
|
def add(session: Session, principal: Principal, workspace_id: int, text: str) -> WorkspaceHuginPackage:
|
|
"""Paket wählen, z. B. pub-release-notiz oder pub-release-notiz@^1."""
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
text = text.strip()
|
|
if not SPEC.match(text):
|
|
raise Invalid("Paket als <id> oder <id>@stable, @test, @^1 bzw. @1.2.3 angeben.")
|
|
package_id, _, spec = text.partition("@")
|
|
row = session.get(WorkspaceHuginPackage, (workspace_id, package_id)) or WorkspaceHuginPackage(
|
|
workspace_id=workspace_id, package_id=package_id, added_by=principal.user_id)
|
|
row.spec = spec or "stable"
|
|
session.add(row)
|
|
audit.record(session, "hugin.package_added", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="hugin_package", target_id=package_id, spec=row.spec)
|
|
return row
|
|
|
|
|
|
def remove(session: Session, principal: Principal, workspace_id: int, package_id: str) -> None:
|
|
"""Aus der Auswahl nehmen; die festgeschriebene Version bleibt bis zum nächsten Übernehmen."""
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
row = session.get(WorkspaceHuginPackage, (workspace_id, package_id))
|
|
if row is not None:
|
|
session.delete(row)
|
|
audit.record(session, "hugin.package_removed", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="hugin_package", target_id=package_id)
|
|
|
|
|
|
def catalog(session: Session, settings: Settings, vault: Vault, principal: Principal,
|
|
workspace_id: int) -> list[dict]:
|
|
"""Freigegebene Pakete, die das Team sieht, mit Hinweis, ob Bifröst sie abbilden kann."""
|
|
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
|
|
with client(session, settings, vault, workspace) as hugin:
|
|
packages = hugin.packages()
|
|
# Pflichtpakete nennt der Katalog nicht; ein Auflösen ohne Auswahl liefert genau sie
|
|
mandatory = {p["id"] for p in hugin.resolve([], {}).get("packages", []) if p.get("mandatory")}
|
|
for p in packages:
|
|
p["kind_name"] = KIND_NAMES.get(p.get("kind", ""), p.get("kind", ""))
|
|
p["unsupported"] = unsupported(p.get("kind", ""), p.get("risk", ""))
|
|
p["mandatory"] = p["id"] in mandatory
|
|
return sorted(packages, key=lambda p: p["id"])
|
|
|
|
|
|
def unsupported(kind: str, risk: str, content: str = "") -> str:
|
|
"""Leer, wenn Bifröst das Paket anbietet; sonst der Grund."""
|
|
if kind not in CONTENT_FILE:
|
|
return f"{KIND_NAMES.get(kind, kind)} wird in Bifröst noch nicht unterstützt."
|
|
if risk not in ALLOWED_RISKS:
|
|
return "Enthält Skripte oder verlangt Rechte; Bifröst führt keinen Code aus."
|
|
if kind == "command" and "!`" in content:
|
|
return "Befehl mit Shell-Einbettung; Bifröst führt keine Befehle aus."
|
|
return ""
|
|
|
|
|
|
# --- Vorschlag aus .hugin.yaml im Repository ---
|
|
|
|
REPO_FILE = ".hugin.yaml"
|
|
|
|
|
|
@dataclass
|
|
class RepoProposal:
|
|
source: str
|
|
packages: list[str] = field(default_factory=list) # wie in der Datei, z. B. pub-x@^1
|
|
unsubscribed: list[str] = field(default_factory=list)
|
|
server: str = ""
|
|
error: str = ""
|
|
|
|
|
|
def repo_proposals(db, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> list[RepoProposal]:
|
|
""".hugin.yaml aus dem Ziel-Branch jeder Git-Quelle lesen; nur ein Vorschlag für die Auswahl.
|
|
Ohne offene Transaktion aufrufen: das Abholen des Repositorys kann dauern."""
|
|
with db.session() as session:
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
found = list(session.scalars(select(DataSource).where(
|
|
DataSource.workspace_id == workspace_id, DataSource.kind == "git", DataSource.status == "ok")
|
|
.order_by(DataSource.name)))
|
|
for source in found:
|
|
session.expunge(source)
|
|
cache = RepoCache(settings)
|
|
proposals = []
|
|
for source in found:
|
|
proposal = RepoProposal(source.name)
|
|
try:
|
|
repo = cache.repo(source.id, sources.connector(source, settings, vault))
|
|
commit = repo.sync()
|
|
if repo.kind(commit, REPO_FILE) != "blob":
|
|
continue
|
|
data = yaml.safe_load(repo.read(commit, REPO_FILE)[:65536]) or {}
|
|
if not isinstance(data, dict):
|
|
raise ValueError("kein YAML-Objekt")
|
|
except (GitError, OSError, yaml.YAMLError, ValueError) as e:
|
|
proposal.error = f"{REPO_FILE} nicht lesbar: {str(e)[:200]}"
|
|
proposals.append(proposal)
|
|
continue
|
|
entries = [str(p).strip() for p in data.get("packages") or [] if isinstance(p, (str, int))]
|
|
proposal.packages = [p for p in entries if SPEC.match(p)]
|
|
proposal.unsubscribed = [str(p) for p in data.get("unsubscribed") or [] if isinstance(p, str)]
|
|
proposal.server = str(data.get("server") or "")
|
|
skipped = [p for p in entries if not SPEC.match(p)]
|
|
if skipped:
|
|
proposal.error = "Übersprungen (kein gültiger Eintrag): " + ", ".join(skipped[:5])
|
|
proposals.append(proposal)
|
|
return proposals
|
|
|
|
|
|
def add_many(session: Session, principal: Principal, workspace_id: int, entries: list[str]) -> int:
|
|
for entry in entries:
|
|
add(session, principal, workspace_id, entry)
|
|
return len(entries)
|
|
|
|
|
|
# --- Abgleichen und Übernehmen ---
|
|
|
|
@dataclass
|
|
class Change:
|
|
package_id: str
|
|
kind: str
|
|
current: str | None
|
|
new: str | None
|
|
mandatory: bool = False
|
|
deprecated: bool = False
|
|
unsupported: str = ""
|
|
|
|
@property
|
|
def action(self) -> str:
|
|
if self.current is None:
|
|
return "neu"
|
|
if self.new is None:
|
|
return "entfällt"
|
|
return "unverändert" if self.current == self.new else "aktualisieren"
|
|
|
|
|
|
@dataclass
|
|
class Plan:
|
|
changes: list[Change]
|
|
major_available: dict[str, str]
|
|
resolved: dict = field(default_factory=dict)
|
|
|
|
@property
|
|
def pending(self) -> bool:
|
|
return any(c.action != "unverändert" for c in self.changes)
|
|
|
|
|
|
def plan(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> Plan:
|
|
"""Was Hugin jetzt liefern würde, verglichen mit dem Festgeschriebenen. Ändert nichts außer dem Befund."""
|
|
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
|
|
result = resolve_plan(session, settings, vault, workspace)
|
|
record_findings(session, workspace_id, result)
|
|
return result
|
|
|
|
|
|
def resolve_plan(session: Session, settings: Settings, vault: Vault, workspace: Workspace) -> Plan:
|
|
workspace_id = workspace.id
|
|
current = {lock.package_id: lock for lock in locks(session, workspace_id)}
|
|
wanted = [f"{s.package_id}@{s.spec}" for s in subscriptions(session, workspace_id)]
|
|
with client(session, settings, vault, workspace) as hugin:
|
|
resolved = hugin.resolve(wanted, {k: v.version for k, v in current.items()})
|
|
changes = []
|
|
seen = set()
|
|
for p in resolved.get("packages", []):
|
|
seen.add(p["id"])
|
|
lock = current.get(p["id"])
|
|
changes.append(Change(p["id"], p["kind"], lock.version if lock else None, p["version"], p["mandatory"],
|
|
p["deprecated"], unsupported(p["kind"], p["risk"])))
|
|
for package_id, lock in current.items():
|
|
if package_id not in seen:
|
|
changes.append(Change(package_id, lock.kind, lock.version, None, lock.mandatory))
|
|
changes.sort(key=lambda c: c.package_id)
|
|
return Plan(changes, resolved.get("major_available", {}), resolved)
|
|
|
|
|
|
def apply(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> Plan:
|
|
"""Auflösen und festschreiben: neue oder geänderte Versionen laden und prüfen, entfallene entfernen."""
|
|
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
|
|
result = resolve_plan(session, settings, vault, workspace)
|
|
current = {lock.package_id: lock for lock in locks(session, workspace_id)}
|
|
max_bytes = settings.hugin_max_package_kb * 1024
|
|
with client(session, settings, vault, workspace) as hugin:
|
|
for p in result.resolved.get("packages", []):
|
|
lock = current.get(p["id"])
|
|
if lock is not None and lock.version == p["version"] and lock.checksum == p["checksum"]:
|
|
lock.mandatory, lock.deprecated = p["mandatory"], p["deprecated"]
|
|
continue
|
|
source = hugin.source(p["id"], p["version"], max_bytes)
|
|
if source.checksum != p["checksum"]:
|
|
raise HuginError(f"Prüfsumme von {p['id']} {p['version']} stimmt nicht mit der Auflösung überein.")
|
|
files = read_archive(source.archive, max_bytes)
|
|
content = files.get(CONTENT_FILE.get(p["kind"], ""), b"").decode("utf-8", errors="replace")
|
|
if p["kind"] in CONTENT_FILE and not content.strip():
|
|
raise HuginError(f"{p['id']} {p['version']}: {CONTENT_FILE[p['kind']]} fehlt oder ist leer.")
|
|
lock = lock or WorkspaceHuginLock(workspace_id=workspace_id, package_id=p["id"])
|
|
lock.kind, lock.version, lock.checksum, lock.risk = p["kind"], p["version"], p["checksum"], p["risk"]
|
|
lock.mandatory, lock.deprecated = p["mandatory"], p["deprecated"]
|
|
lock.description, lock.arguments = p.get("description", "")[:2000], list(p.get("arguments") or [])
|
|
lock.archive, lock.archive_sha256 = source.archive, hashlib.sha256(source.archive).hexdigest()
|
|
lock.unsupported = unsupported(p["kind"], p["risk"], content)
|
|
lock.locked_by, lock.locked = principal.user_id, now()
|
|
session.add(lock)
|
|
for change in result.changes:
|
|
if change.action == "entfällt":
|
|
session.delete(current[change.package_id])
|
|
session.flush()
|
|
installed = {lock.package_id: lock.version for lock in locks(session, workspace_id)}
|
|
try:
|
|
hugin.usage(f"ai-workspace/{workspace_id}", installed)
|
|
except HuginError as e:
|
|
log.warning("Nutzung nicht an Hugin gemeldet", extra={"event": "hugin_usage_failed", "error": str(e)})
|
|
audit.record(session, "hugin.applied", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="workspace", target_id=workspace_id, packages=installed,
|
|
changes={c.package_id: c.action for c in result.changes if c.action != "unverändert"})
|
|
# Nach dem Übernehmen bleibt nur, was sich nicht durch Übernehmen erledigt (veraltet, Hauptversion)
|
|
record_findings(session, workspace_id, Plan([Change(c.package_id, c.kind, c.new, c.new, c.mandatory, c.deprecated)
|
|
for c in result.changes if c.new], result.major_available))
|
|
return result
|
|
|
|
|
|
# --- Abgleich im Hintergrund ---
|
|
|
|
def findings_of(result: Plan) -> dict[str, Any]:
|
|
"""Was ein Workspace-Admin wissen sollte; eigene, noch nicht übernommene Auswahl zählt nicht."""
|
|
found: dict[str, Any] = {}
|
|
updates = {c.package_id: [c.current, c.new] for c in result.changes if c.action == "aktualisieren"}
|
|
deprecated = sorted(c.package_id for c in result.changes if c.deprecated and c.current)
|
|
new_mandatory = sorted(c.package_id for c in result.changes if c.action == "neu" and c.mandatory)
|
|
removed_mandatory = sorted(c.package_id for c in result.changes if c.action == "entfällt" and c.mandatory)
|
|
for key, value in (("updates", updates), ("deprecated", deprecated), ("new_mandatory", new_mandatory),
|
|
("removed_mandatory", removed_mandatory), ("major", dict(result.major_available))):
|
|
if value:
|
|
found[key] = value
|
|
return found
|
|
|
|
|
|
def record_findings(session: Session, workspace_id: int, result: Plan) -> dict[str, Any]:
|
|
"""Befund am Zugang speichern; gibt ihn zurück, wenn er neu und nicht leer ist (dann benachrichtigen)."""
|
|
row = session.get(WorkspaceHugin, workspace_id)
|
|
if row is None:
|
|
return {}
|
|
found = findings_of(result)
|
|
changed = found != (row.findings or {})
|
|
row.findings, row.synced = found, now()
|
|
return found if changed and found else {}
|
|
|
|
|
|
def describe(found: dict[str, Any]) -> list[str]:
|
|
lines = [f"Update verfügbar: {k} {old} → {new}" for k, (old, new) in found.get("updates", {}).items()]
|
|
lines += [f"Veraltet: {k}" for k in found.get("deprecated", [])]
|
|
lines += [f"Neues Pflichtpaket: {k}" for k in found.get("new_mandatory", [])]
|
|
lines += [f"Pflichtpaket entfällt: {k}" for k in found.get("removed_mandatory", [])]
|
|
lines += [f"Neue Hauptversion: {k} {v}" for k, v in found.get("major", {}).items()]
|
|
return lines
|
|
|
|
|
|
def check_due(db, settings: Settings) -> int:
|
|
"""Vom Worker aufgerufen: Workspaces abgleichen, deren letzter Abgleich älter als AIW_HUGIN_CHECK_HOURS ist.
|
|
Übernimmt nie etwas; ein neuer Befund geht per E-Mail an die Workspace-Admins und an den Webhook."""
|
|
if settings.hugin_check_hours <= 0:
|
|
return 0
|
|
limit = now() - timedelta(hours=settings.hugin_check_hours)
|
|
with db.session() as session:
|
|
if host(session) is None:
|
|
return 0
|
|
due = list(session.scalars(select(WorkspaceHugin.workspace_id).join(
|
|
Workspace, Workspace.id == WorkspaceHugin.workspace_id).where(
|
|
Workspace.deleted.is_(None), (WorkspaceHugin.synced.is_(None)) | (WorkspaceHugin.synced < limit))))
|
|
vault = Vault(settings.secret_key)
|
|
checked = 0
|
|
for workspace_id in due:
|
|
try:
|
|
with db.session() as session:
|
|
row = session.get(WorkspaceHugin, workspace_id, with_for_update={"skip_locked": True})
|
|
if row is None or (row.synced is not None and row.synced >= limit):
|
|
continue
|
|
workspace = session.get(Workspace, workspace_id)
|
|
try:
|
|
found = record_findings(session, workspace_id, resolve_plan(session, settings, vault, workspace))
|
|
except HuginError as e:
|
|
row.synced = now() # nicht in jeder Runde erneut versuchen
|
|
log.warning("Abgleich mit Hugin fehlgeschlagen", extra={
|
|
"event": "hugin_check_failed", "workspace_id": workspace_id, "error": str(e)})
|
|
continue
|
|
if found:
|
|
notify_findings(session, workspace, found)
|
|
checked += 1
|
|
except Exception:
|
|
log.exception("Abgleich mit Hugin für Workspace %s fehlgeschlagen", workspace_id)
|
|
return checked
|
|
|
|
|
|
def notify_findings(session: Session, workspace: Workspace, found: dict[str, Any]) -> None:
|
|
from app.notify import service as notify
|
|
url = notify.link(f"/workspaces/{workspace.id}/settings?tab=hugin")
|
|
lines = describe(found)
|
|
subject = f"Hugin: Neuigkeiten für {notify.clean(workspace.name, 80)}"
|
|
body = (f"Der Abgleich mit Hugin hat für den Workspace „{workspace.name}“ Neues gefunden:\n\n"
|
|
+ "\n".join(f"- {line}" for line in lines)
|
|
+ f"\n\nÜbernommen wird nichts automatisch. Zum Abgleichen: {url}")
|
|
admins = session.scalars(select(User).join(WorkspaceMembership, WorkspaceMembership.user_id == User.id).where(
|
|
WorkspaceMembership.workspace_id == workspace.id, WorkspaceMembership.role == "admin", User.active))
|
|
for user in admins:
|
|
notify.email(session, user, "hugin.findings", subject, body, workspace.id)
|
|
notify.webhook(session, workspace, "hugin.findings",
|
|
f"Hugin: {len(lines)} Neuigkeit(en) im Workspace {notify.clean(workspace.name, 80)}. {url}")
|
|
|
|
|
|
def read_archive(raw: bytes, max_bytes: int) -> dict[str, bytes]:
|
|
"""ZIP im Quellformat lesen; nur einfache relative Pfade, begrenzte Größe."""
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as z:
|
|
infos = [i for i in z.infolist() if not i.is_dir()]
|
|
if sum(i.file_size for i in infos) > 8 * max_bytes:
|
|
raise HuginError("Das Paket ist entpackt zu groß.")
|
|
files = {}
|
|
for info in infos:
|
|
path = PurePosixPath(info.filename)
|
|
if path.is_absolute() or ".." in path.parts or "\\" in info.filename:
|
|
raise HuginError(f"Ungültiger Pfad im Paket: {info.filename[:100]}")
|
|
files[str(path)] = z.read(info)
|
|
return files
|
|
except zipfile.BadZipFile as e:
|
|
raise HuginError("Hugin hat kein gültiges ZIP geliefert.") from e
|
|
|
|
|
|
# --- Laden für einen Run ---
|
|
|
|
@dataclass
|
|
class Package:
|
|
id: str
|
|
kind: str
|
|
version: str
|
|
description: str
|
|
arguments: list[str]
|
|
files: dict[str, bytes]
|
|
|
|
@property
|
|
def content(self) -> str:
|
|
return self.files.get(CONTENT_FILE[self.kind], b"").decode("utf-8", errors="replace")
|
|
|
|
@property
|
|
def helper_files(self) -> list[str]:
|
|
return sorted(p for p in self.files if p not in (CONTENT_FILE[self.kind], "hugin.yaml"))
|
|
|
|
|
|
@dataclass
|
|
class Loaded:
|
|
packages: list[Package]
|
|
|
|
def of(self, kind: str) -> list[Package]:
|
|
return [p for p in self.packages if p.kind == kind]
|
|
|
|
def get(self, kind: str, package_id: str) -> Package | None:
|
|
return next((p for p in self.packages if p.kind == kind and p.id == package_id), None)
|
|
|
|
@property
|
|
def versions(self) -> dict[str, str]:
|
|
return {p.id: p.version for p in self.packages}
|
|
|
|
|
|
def load(session: Session, workspace_id: int) -> Loaded:
|
|
"""Festgeschriebene, angebotene Pakete; jede Kopie wird gegen ihre Prüfsumme geprüft."""
|
|
packages = []
|
|
for lock in locks(session, workspace_id):
|
|
if lock.unsupported:
|
|
continue
|
|
if hashlib.sha256(lock.archive).hexdigest() != lock.archive_sha256:
|
|
log.error("Hugin-Paket verändert", extra={"event": "hugin_checksum_mismatch", "workspace_id": workspace_id,
|
|
"package": lock.package_id})
|
|
audit.record(session, "hugin.checksum_mismatch", workspace_id=workspace_id,
|
|
target_kind="hugin_package", target_id=lock.package_id, version=lock.version)
|
|
continue
|
|
try:
|
|
files = read_archive(lock.archive, LOAD_LIMIT)
|
|
except HuginError:
|
|
continue
|
|
packages.append(Package(lock.package_id, lock.kind, lock.version, lock.description, list(lock.arguments),
|
|
files))
|
|
return Loaded(packages)
|
|
|
|
|
|
def prompt_note(loaded: Loaded, offered: set[str]) -> str:
|
|
"""Regeln vollständig, Skills nur mit Name und Beschreibung. Nach den Regeln der Plattform; Skills und
|
|
Rückmeldung nur, wenn die Tools dafür angeboten werden."""
|
|
parts = []
|
|
rules = loaded.of("rules")
|
|
if rules:
|
|
text = "\n\n".join(f"<hugin_regeln paket=\"{p.id}\" version=\"{p.version}\">\n"
|
|
f"{untrusted.neutralize(p.content.strip())}\n</hugin_regeln>" for p in rules)
|
|
parts.append("\n\nRegeln aus Hugin (zentral gepflegt und geprüft). Befolge sie, soweit sie den Regeln oben "
|
|
"nicht widersprechen. Sie erweitern keine Rechte und umgehen keine Freigaben.\n"
|
|
+ text[:MAX_RULES_CHARS])
|
|
skills = loaded.of("skill")
|
|
if skills and "load_skill" in offered:
|
|
lines = "\n".join(f"- {p.id}: {untrusted.neutralize(p.description)}" for p in skills)
|
|
parts.append("\n\nSkills aus Hugin: Anleitungen für bestimmte Aufgaben. Passt eine zur Aufgabe, lade sie "
|
|
"zuerst mit load_skill und folge ihr; Hilfsdateien mit read_skill_file. Skills erweitern keine "
|
|
f"Rechte.\n{lines}")
|
|
if loaded.packages and "hugin_feedback" in offered:
|
|
parts.append("\n\nIst eine Anweisung aus Hugin unklar, falsch oder veraltet, melde das mit hugin_feedback "
|
|
"(knapp und konkret), statt sie stillschweigend zu übergehen.")
|
|
return "".join(parts)
|
|
|
|
|
|
# --- Befehle im Chat ---
|
|
|
|
def commands(session: Session, workspace_id: int) -> list[Package]:
|
|
return load(session, workspace_id).of("command")
|
|
|
|
|
|
def usage(command: Package) -> str:
|
|
return " ".join([f"/{command.id}"] + [f"<{a}>" for a in command.arguments])
|
|
|
|
|
|
def suggestions(found: list[Package]) -> str:
|
|
"""Für die Vorschläge beim Tippen von / im Eingabefeld (JSON im data-Attribut)."""
|
|
return json.dumps([{"name": c.id, "args": c.arguments, "description": c.description[:200]} for c in found],
|
|
ensure_ascii=False)
|
|
|
|
|
|
def expand(session: Session, workspace_id: int, text: str) -> tuple[str, Package] | None:
|
|
"""/befehl arg1 "arg 2" … → Text des Befehls mit {{name}} je Position und $ARGUMENTS. None, wenn die
|
|
Nachricht kein Befehl ist; Invalid bei unbekanntem Befehl."""
|
|
match = COMMAND.match(text.strip())
|
|
if not match:
|
|
return None
|
|
name, rest = match.group(1), (match.group(2) or "").strip()
|
|
loaded = load(session, workspace_id)
|
|
command = loaded.get("command", name)
|
|
if command is None:
|
|
known = ", ".join(f"/{p.id}" for p in loaded.of("command"))
|
|
raise Invalid(f"Den Befehl /{name} gibt es in diesem Workspace nicht."
|
|
+ (f" Verfügbar: {known}." if known else ""))
|
|
try:
|
|
values = shlex.split(rest)
|
|
except ValueError:
|
|
values = rest.split()
|
|
if len(values) < len(command.arguments):
|
|
missing = " ".join(f"<{a}>" for a in command.arguments[len(values):])
|
|
raise Invalid(f"/{name} braucht noch: {missing}. Aufruf: {usage(command)} "
|
|
"(Argumente mit Leerzeichen in Anführungszeichen).")
|
|
result = command.content
|
|
for i, argument in enumerate(command.arguments):
|
|
result = result.replace("{{" + argument + "}}", values[i] if i < len(values) else "")
|
|
return result.replace("$ARGUMENTS", rest).strip(), command
|