Compare commits

...

7 Commits

Author SHA1 Message Date
RubenRWU
c0d3ec5dab UI: Formular nach Agent-Übernahme neu aufbauen (zeigte alte Werte); Cache-Busting für JS/CSS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:51:16 +02:00
RubenRWU
40b3f687f7 KI-Agent: Änderungen erscheinen in Live-Vorschau und PDF
- Dateien des Agenten werden sofort committet (die Vorschau liest in anderer Sitzung; vorher KeyError/unverändert)
- Beispielprotokoll der PDF-Vorschau gehört zum Arbeitsstand: neues Tool set_sample, run_prompt_test setzt das LLM-Ergebnis als Beispiel; im Vorschlag und bei der Freigabe übernommen
- Vorschau wechselt bei PDF-relevanten Änderungen automatisch auf die PDF-Ansicht
- Regressionstests inkl. echter SSE-/Freigabe-Strecke

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:46:32 +02:00
RubenRWU
9c352be05b Health-Check: DNS-over-HTTPS-Rückfall bei nicht auflösbarem Hostnamen, DNS-Problem des Admin-Servers nur Warnung (außer strikte Rollouts); COST_INGEST_KEY optional (Schema-getrieben)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:36:54 +02:00
RubenRWU
31a99e31c0 Deploy: Health-Check wartet bei neuem Ingress geduldig auf Zertifikat/DNS (bis ~15 min), endet dann mit Warnung statt failed; Zustand wird vor dem Health-Check festgehalten; 'Health erneut prüfen'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:24:36 +02:00
RubenRWU
f33312369f Test: HTTP-Attrappe um headers ergänzt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:19:31 +02:00
RubenRWU
8000f76457 Mittwald-/Login-API: Weiterleitungen folgen (wie requests); nicht gefolgte 3xx-Antwort ist ein Fehler statt leerer Erfolg
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:19:01 +02:00
RubenRWU
c45a6a0561 Vorflug-Check: Hinweis, dass der veröffentlichte Stand geprüft wird
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:13:27 +02:00
26 changed files with 485 additions and 64 deletions

View File

@@ -19,11 +19,11 @@ from app.services import llm, store
MAX_TURNS = 14 MAX_TURNS = 14
# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24. # Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24.
WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict() WORKSPACES: "OrderedDict[str, tuple[dict, dict, str | None]]" = OrderedDict()
def remember(run_id: str, ws: Workspace) -> None: def remember(run_id: str, ws: Workspace) -> None:
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files)) WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files), ws.sample)
WORKSPACES.move_to_end(run_id) WORKSPACES.move_to_end(run_id)
while len(WORKSPACES) > 24: while len(WORKSPACES) > 24:
WORKSPACES.popitem(last=False) WORKSPACES.popitem(last=False)
@@ -39,6 +39,7 @@ Arbeitsweise:
- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese. - Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese.
- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht. - Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht.
- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest. - Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest.
- Die PDF-Vorschau rendert ein BEISPIELPROTOKOLL. Prompt-Änderungen wirken sich darauf nicht aus: Willst du ihre Wirkung im PDF zeigen, rufe run_prompt_test auf (das Ergebnis wird zum Beispiel) – für Layout-Tests mit passendem Inhalt nutze set_sample.
- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes. - Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes.
- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an. - Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an.
- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden. - Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden.
@@ -89,7 +90,7 @@ def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterato
ws = Workspace(db, tenant) ws = Workspace(db, tenant)
run_id = uuid.uuid4().hex[:12] run_id = uuid.uuid4().hex[:12]
remember(run_id, ws) remember(run_id, ws)
last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True) last_sig, last_snap = json.dumps([ws.cfg, ws.files, ws.sample], sort_keys=True), ws.snapshot()
runner = ToolRunner(ws) runner = ToolRunner(ws)
system = SYSTEM + "\n\n" + _context(tenant, ws) system = SYSTEM + "\n\n" + _context(tenant, ws)
messages = _history(db, tenant.id) messages = _history(db, tenant.id)
@@ -130,11 +131,12 @@ def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterato
except (ValueError, KeyError): except (ValueError, KeyError):
pass pass
yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")} yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")}
sig = json.dumps([ws.cfg, ws.files], sort_keys=True) sig = json.dumps([ws.cfg, ws.files, ws.sample], sort_keys=True)
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
last_sig = sig paths = ws.changed_paths(last_snap)
last_sig, last_snap = sig, ws.snapshot()
remember(run_id, ws) remember(run_id, ws)
yield {"type": "changed", "run": run_id} yield {"type": "changed", "run": run_id, "paths": paths}
results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res}) results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res})
messages.append({"role": "user", "content": results}) messages.append({"role": "user", "content": results})
else: else:
@@ -162,6 +164,8 @@ def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
store.set_file(db, tenant, path, store.get_blob(db, sha)) store.set_file(db, tenant, path, store.get_blob(db, sha))
for path in proposal.get("deleted") or []: for path in proposal.get("deleted") or []:
store.delete_file(db, tenant, path) store.delete_file(db, tenant, path)
if proposal.get("sample") is not None:
tenant.preview_sample = proposal["sample"]
def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]: def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]:

View File

@@ -30,6 +30,9 @@ TOOLS = [
"schema": {"type": "object", "properties": {}}}, "schema": {"type": "object", "properties": {}}},
{"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.", {"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.",
"schema": {"type": "object", "properties": {}}}, "schema": {"type": "object", "properties": {}}},
{"name": "set_sample", "description": "Setzt das Beispielprotokoll, das die PDF-Vorschau rendert (Format wie vom LLM: ===TERMINART=== … ===/TERMINART===, dann Überschriften und Text). "
"Sinnvoll, um Layout-Änderungen mit passendem Inhalt zu prüfen.",
"schema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}},
{"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.", {"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.",
"schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}}, "schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}},
] ]
@@ -41,6 +44,7 @@ class ToolRunner:
self.llm_calls = 0 self.llm_calls = 0
def _preview(self, kind, **kw): def _preview(self, kind, **kw):
kw.setdefault("sample", self.ws.sample) # Beispielprotokoll des Workspace (None → Standard des Tenants)
return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw) return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw)
def run(self, name: str, a: dict) -> tuple[str, str]: def run(self, name: str, a: dict) -> tuple[str, str]:
@@ -107,4 +111,11 @@ class ToolRunner:
self.llm_calls += 1 self.llm_calls += 1
from app.agent.testrun import prompt_test from app.agent.testrun import prompt_test
res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript")) res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript"))
return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}" if res["text"].strip():
self.ws.sample = res["text"] # das erzeugte Protokoll wird zum Beispiel der PDF-Vorschau → Prompt-Änderungen sind im PDF sichtbar
return "Beispieltranskript", (f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens] Das Ergebnis ist jetzt das Beispielprotokoll "
f"der PDF-Vorschau.\n{res['text'][:6000]}")
def t_set_sample(self, a):
self.ws.sample = a["text"]
return "Beispielprotokoll", f"gesetzt ({len(a['text'])} Zeichen) – die PDF-Vorschau rendert jetzt dieses Protokoll"

View File

@@ -29,6 +29,7 @@ class Workspace:
cfg, files = store.content(db, tenant, "draft") cfg, files = store.content(db, tenant, "draft")
self.base_cfg, self.base_files = deepcopy(cfg), dict(files) self.base_cfg, self.base_files = deepcopy(cfg), dict(files)
self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha} self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha}
self.base_sample = self.sample = tenant.preview_sample # Beispielprotokoll der PDF-Vorschau (None = Standard)
# ── Lesen ── # ── Lesen ──
def list_files(self) -> list[dict]: def list_files(self) -> list[dict]:
@@ -60,6 +61,7 @@ class Workspace:
if path.endswith(".py"): if path.endswith(".py"):
compile(content, path, "exec") # Syntaxfehler früh melden compile(content, path, "exec") # Syntaxfehler früh melden
self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path) self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path)
self.db.commit() # Blob sofort sichtbar machen: die Live-Vorschau liest in einer ANDEREN Sitzung, während der Agent noch läuft
def edit(self, path: str, old: str, new: str) -> None: def edit(self, path: str, old: str, new: str) -> None:
cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False) cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False)
@@ -90,7 +92,24 @@ class Workspace:
# ── Ergebnis ── # ── Ergebnis ──
def changed(self) -> bool: def changed(self) -> bool:
return self.cfg != self.base_cfg or self.files != self.base_files return self.cfg != self.base_cfg or self.files != self.base_files or self.sample != self.base_sample
def snapshot(self) -> tuple[dict, dict, str | None]:
return deepcopy(self.cfg), dict(self.files), self.sample
def changed_paths(self, prev: tuple[dict, dict, str | None]) -> list[str]:
"""Was hat sich seit `prev` geändert? (für die Vorschau: PDF-relevante Änderungen → PDF-Ansicht)"""
cfg0, files0, sample0 = prev
out = [p for p in set(self.files) | set(files0) if self.files.get(p) != files0.get(p)]
for k in set(self.cfg) | set(cfg0):
if self.cfg.get(k) != cfg0.get(k):
if isinstance(self.cfg.get(k), dict) or isinstance(cfg0.get(k), dict):
out += [f"{k}.{kk}" for kk in set(self.cfg.get(k) or {}) | set(cfg0.get(k) or {}) if (self.cfg.get(k) or {}).get(kk) != (cfg0.get(k) or {}).get(kk)]
else:
out.append(k)
if self.sample != sample0:
out.append("preview_sample")
return sorted(out)
def proposal(self, summary: str) -> dict | None: def proposal(self, summary: str) -> dict | None:
if not self.changed(): if not self.changed():
@@ -102,5 +121,7 @@ class Workspace:
items.append({"path": "tenant.json", "kind": "geändert"}) items.append({"path": "tenant.json", "kind": "geändert"})
items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files] items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files]
items += [{"path": p, "kind": "gelöscht"} for p in deleted] items += [{"path": p, "kind": "gelöscht"} for p in deleted]
if self.sample != self.base_sample:
items.append({"path": "Vorschau-Beispielprotokoll", "kind": "geändert"})
return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None, return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None,
"files": files, "deleted": deleted, "items": items} "files": files, "deleted": deleted, "items": items, "sample": self.sample if self.sample != self.base_sample else None}

View File

@@ -474,6 +474,12 @@ def tenant_preflight(t: Tenant = Depends(tenant_or_404)):
return preflight.run(t.id) return preflight.run(t.id)
@router.post("/tenants/{tenant_id}/health-check")
def tenant_health_check(t: Tenant = Depends(tenant_or_404)):
"""Health-Check nachholen (z. B. nach neuem Ingress, wenn das Zertifikat inzwischen da ist). Schreibt nichts außer dem Deploy-Status."""
return deploy.recheck_health(t.id)
@router.get("/tenants/{tenant_id}/plan") @router.get("/tenants/{tenant_id}/plan")
def plan(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)): def plan(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return deploy.plan(db, t) return deploy.plan(db, t)

View File

@@ -17,13 +17,13 @@ COLOR_LABELS = {
} }
# Secret-Schlüssel = Env-Namen im Container (siehe infra/deploy.py) # Secret-Schlüssel = Env-Namen im Container (siehe infra/deploy.py)
REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY"] REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET"] # Rückfall; maßgeblich ist das Schema des Cores (contract.required_secrets)
OPTIONAL_SECRETS = ["MISTRAL_API_KEY"] OPTIONAL_SECRETS = ["MISTRAL_API_KEY"]
SECRET_HELP = { SECRET_HELP = {
"OIDC_CLIENT_ID": "Client-ID aus login.onyva.dev (manuell angelegt)", "OIDC_CLIENT_ID": "Client-ID aus login.onyva.dev (manuell angelegt)",
"OIDC_CLIENT_SECRET": "Client-Secret aus login.onyva.dev", "OIDC_CLIENT_SECRET": "Client-Secret aus login.onyva.dev",
"SESSION_SECRET": "Signiert Session-Cookies – kann hier erzeugt werden", "SESSION_SECRET": "Signiert Session-Cookies – kann hier erzeugt werden",
"COST_INGEST_KEY": "Schlüssel für die Kostenerfassung (Cost-API)", "COST_INGEST_KEY": "Optional – Schlüssel für die Kostenerfassung (Cost-API); ohne Key bleibt sie inaktiv",
"MISTRAL_API_KEY": "Nur nötig bei STT-Provider „mistral“ (bei „cortecs“ wird der geteilte Key genutzt)", "MISTRAL_API_KEY": "Nur nötig bei STT-Provider „mistral“ (bei „cortecs“ wird der geteilte Key genutzt)",
"ADMIN_BUNDLE_TOKEN": "Token, mit dem der Container sein Bundle bei der Admin-Plattform abholt", "ADMIN_BUNDLE_TOKEN": "Token, mit dem der Container sein Bundle bei der Admin-Plattform abholt",
} }

View File

@@ -16,7 +16,7 @@ from sqlalchemy import select
from app.db import session from app.db import session
from app.models import Deployment, Secret, Setting, Tenant from app.models import Deployment, Secret, Setting, Tenant
from app.services import contract, crypto, guard, onyva_login, store from app.services import contract, crypto, guard, netcheck, onyva_login, store
from app.services.mittwald import DeployError, Mittwald, mask_env from app.services.mittwald import DeployError, Mittwald, mask_env
PORT = "8276" PORT = "8276"
@@ -135,24 +135,50 @@ def _client(live: bool, log, http=None) -> Mittwald:
return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http) return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http)
def recheck_health(tenant_id: str, *, health=None, tries: int = 8, delay: float = 5.0) -> dict:
"""Health-Check nachholen (z. B. wenn das Zertifikat inzwischen da ist). Bei Erfolg wird ein hängender Deploy-Eintrag auf „success“ gesetzt."""
lines: list[str] = []
with session() as db:
t = db.get(Tenant, tenant_id)
host, tag, ver, source = host_for(db, t), effective_tag(db, t), t.current_version, t.source
deployed = (t.deployed_version, t.deployed_image)
try:
args = (host, deployed[1] or tag, ver if source == "admin" else None, lines.append)
health(*args) if health else verify_health(*args, tries=tries, delay=delay)
except DeployError as exc:
return {"ok": False, "log": lines + [f"✘ {exc}"]}
with session() as db:
d = db.scalar(select(Deployment).where(Deployment.tenant_id == tenant_id, Deployment.kind == "deploy").order_by(Deployment.id.desc()))
if d is not None and d.status in ("warning", "failed") and not d.simulated:
d.status = "success"
d.log += f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} Health nachträglich ✔ – Deploy als erfolgreich markiert\n"
return {"ok": True, "log": lines}
def _hex_tag(tag: str) -> bool: def _hex_tag(tag: str) -> bool:
return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7 return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7
class HealthTimeout(DeployError):
"""Health-Check nicht grün geworden. answered=False: es kam nie eine gültige Antwort (Zertifikat/DNS/Container noch nicht bereit)."""
def __init__(self, msg: str, answered: bool, dns: bool = False):
super().__init__(msg)
self.answered = answered
self.dns = dns # Hostname war vom Admin-Server aus nicht auflösbar (Resolver-Cache/DNS), nicht Fehler des Containers
def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0, def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0,
sleep=time.sleep) -> None: sleep=time.sleep) -> None:
"""Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle). """Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle).
Wirft DeployError, wenn der Container nach `tries` Versuchen nicht gesund ist oder die falsche Version meldet.""" Wirft DeployError, wenn der Container nach `tries` Versuchen nicht gesund ist oder die falsche Version meldet."""
if get is None: if get is None:
import httpx get = netcheck.get_json
last, answered, dns = "keine Antwort", False, False
def get(url):
r = httpx.get(url, timeout=8, follow_redirects=True)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
last = "keine Antwort"
for i in range(tries): for i in range(tries):
try: try:
status, body = get(f"https://{host}/health") status, body = get(f"https://{host}/health")
answered = answered or (status == 200 and isinstance(body, dict) and "status" in body) # echte Antwort der App (nicht Proxy/Zertifikatsfehler)
problems = [] problems = []
if status != 200 or body.get("status") != "ok": if status != 200 or body.get("status") != "ok":
problems.append(f"HTTP {status}") problems.append(f"HTTP {status}")
@@ -168,18 +194,21 @@ def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None,
problems.append(f"Bundle {b.get('source')} v{b.get('version')}{' (veraltet)' if b.get('stale') else ''} statt admin v{expect_bundle}" problems.append(f"Bundle {b.get('source')} v{b.get('version')}{' (veraltet)' if b.get('stale') else ''} statt admin v{expect_bundle}"
+ (f" – {b.get('error')}" if b.get("error") else "")) + (f" – {b.get('error')}" if b.get("error") else ""))
if not problems: if not problems:
if body.get("_dns_fallback"):
log("(Hinweis: Hostname vom Admin-Server nicht auflösbar – Prüfung über DNS-over-HTTPS direkt zur IP)")
log(f"Health-Check https://{host}/health ✔" + (f" · Core {core['version'][:10]}" if core else "") log(f"Health-Check https://{host}/health ✔" + (f" · Core {core['version'][:10]}" if core else "")
+ (f" · Bundle v{body['bundle']['version']}" if body.get("bundle", {}).get("source") == "admin" else "")) + (f" · Bundle v{body['bundle']['version']}" if body.get("bundle", {}).get("source") == "admin" else ""))
return return
last = "; ".join(problems) last = "; ".join(problems)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
last = f"{type(exc).__name__}: {exc}" last = f"{type(exc).__name__}: {exc}"
dns = netcheck.is_dns_failure_text(last)
if i < tries - 1: if i < tries - 1:
sleep(delay) sleep(delay)
raise DeployError(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}") raise HealthTimeout(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}", answered, dns and not answered)
def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None) -> bool: def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None, strict: bool = False) -> bool:
"""Führt einen Deployment-Datensatz aus. kind: dry-run | deploy | offboard-dry | offboard. """Führt einen Deployment-Datensatz aus. kind: dry-run | deploy | offboard-dry | offboard.
core_tag überschreibt den Core-Tag (Rollout/Rollback); `health(host, expect_sha, expect_bundle, log)` ist in Tests ersetzbar.""" core_tag überschreibt den Core-Tag (Rollout/Rollback); `health(host, expect_sha, expect_bundle, log)` ist in Tests ersetzbar."""
log = lambda m: _append(dep_id, m) # noqa: E731 log = lambda m: _append(dep_id, m) # noqa: E731
@@ -230,16 +259,37 @@ def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None,
else: else:
mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition, mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition,
container_port=PORT, project_id=project, hostname=host) container_port=PORT, project_id=project, hostname=host)
if live and not kind.startswith("offboard") and host: warning = False
(health or verify_health)(host, tag, ver if source == "admin" else None, log) if not dry: # Zustand sofort festhalten: geschrieben ist geschrieben, auch wenn der Health-Check noch wartet
if not dry:
with session() as db: with session() as db:
t = db.get(Tenant, tenant_id) t = db.get(Tenant, tenant_id)
if kind == "deploy": if kind == "deploy":
t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc) t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc)
store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}") store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}")
log("✔ Fertig" if live else "Dry-Run beendet – es wurde nichts geschrieben.") if live and not kind.startswith("offboard") and host:
return True fresh = mw.ingress_created
# Neuer Ingress: Zertifikat/DNS bei Mittwald brauchen oft mehrere Minuten → geduldig warten (bis ~15 min)
kw = {"tries": 90, "delay": 10.0} if fresh else {}
if fresh:
log("Neuer Ingress angelegt – Zertifikat/DNS bei Mittwald können einige Minuten dauern, Health-Check wartet geduldig …")
try:
args = (host, tag, ver if source == "admin" else None, log)
health(*args) if health else verify_health(*args, **kw)
except HealthTimeout as exc:
# Warnung statt Fehler: neuer Ingress ohne Antwort (Zertifikat/DNS) – oder (außerhalb strikter Rollouts) ein Namensauflösungsproblem des Admin-Servers
if not exc.answered and (fresh or (exc.dns and not strict)):
warning = True
log(f"⚠ {exc}")
if exc.dns:
log("⚠ Der Admin-Server kann den Hostnamen nicht auflösen (Resolver-Cache/DNS) – der Deploy selbst ist geschrieben. "
"Prüfe im Browser, ob die Adresse läuft, und später „Health erneut prüfen“.")
else:
log("⚠ Container und Ingress sind angelegt, aber die Adresse antwortet noch nicht (Zertifikat/DNS?). "
"Später „Health erneut prüfen“ (Deploy-Tab) oder „Läuft?“ (Übersicht).")
else:
raise
log("✔ Fertig" if live and not warning else ("Fertig mit Warnung" if warning else "Dry-Run beendet – es wurde nichts geschrieben."))
return "warning" if warning else True
except DeployError as exc: except DeployError as exc:
log(f"✘ {exc}") log(f"✘ {exc}")
return False return False
@@ -260,7 +310,7 @@ def start(tenant_id: str, kind: str, actor: str, batch_id: str = "", http=None,
def work(): def work():
ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health) ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health)
_finish(dep_id, "success" if ok else "failed") _finish(dep_id, "warning" if ok == "warning" else ("success" if ok else "failed"))
th = threading.Thread(target=work, daemon=True) th = threading.Thread(target=work, daemon=True)
th.start() th.start()
@@ -302,9 +352,9 @@ def run_sequence(tenant_ids: list[str], kind: str, actor: str, *, batch: str, co
continue continue
with session() as db: with session() as db:
db.get(Deployment, did).status = "running" db.get(Deployment, did).status = "running"
ok = execute(did, tid, kind, http, oidc_client, core_tag, health) ok = execute(did, tid, kind, http, oidc_client, core_tag, health, strict=bool(core_tag))
_finish(did, "success" if ok else "failed") _finish(did, "warning" if ok == "warning" else ("success" if ok else "failed"))
results[tid] = "success" if ok else "failed" results[tid] = "warning" if ok == "warning" else ("success" if ok else "failed")
failed = not ok failed = not ok
if tid in canary and ok and tid == canary[-1]: if tid in canary and ok and tid == canary[-1]:
_append(did, "Canary erfolgreich – restliche Tenants folgen") _append(did, "Canary erfolgreich – restliche Tenants folgen")

View File

@@ -30,10 +30,11 @@ class Mittwald:
self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log
self._http = http or self._real_http self._http = http or self._real_http
self._project_uuid: str | None = None self._project_uuid: str | None = None
self.ingress_created = False # wurde in diesem Lauf ein NEUER Ingress angelegt? (dann dauert Zertifikat/DNS)
@staticmethod @staticmethod
def _real_http(method, url, headers, body): def _real_http(method, url, headers, body):
r = httpx.request(method, url, headers=headers, json=body, timeout=60) r = httpx.request(method, url, headers=headers, json=body, timeout=60, follow_redirects=True) # wie `requests` im alten deploy.py
try: try:
data = r.json() if r.content else None data = r.json() if r.content else None
except ValueError: except ValueError:
@@ -56,7 +57,7 @@ class Mittwald:
raise DryRunSkip() raise DryRunSkip()
raise DeployError("MITTWALD_API_TOKEN nicht gesetzt.") raise DeployError("MITTWALD_API_TOKEN nicht gesetzt.")
status, data, text = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, body) status, data, text = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, body)
if status >= 400: if status >= 300: # 3xx (nicht gefolgte Weiterleitung) darf nie als Erfolg mit leerem Body durchgehen
msg = f"{method} {path} → HTTP {status}: {text}" msg = f"{method} {path} → HTTP {status}: {text}"
if fatal: if fatal:
raise DeployError(msg) raise DeployError(msg)
@@ -151,6 +152,8 @@ class Mittwald:
body = {"hostname": hostname, "projectId": self.project_uuid(project_id), body = {"hostname": hostname, "projectId": self.project_uuid(project_id),
"paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]} "paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]}
res = self.try_api("POST", "/ingresses", body, write=True, fatal=False) res = self.try_api("POST", "/ingresses", body, write=True, fatal=False)
if self.live and res is not None:
self.ingress_created = True
if self.live: if self.live:
self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else
f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).") f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).")

60
app/services/netcheck.py Normal file
View File

@@ -0,0 +1,60 @@
"""HTTP-GET mit DNS-Rückfall für Health-Checks.
Der Resolver des Admin-Servers (Coolify/Docker) cacht „Name nicht gefunden“ für neue Hostnamen oft lange – obwohl der Name öffentlich
längst existiert. Schlägt die Auflösung fehl, fragen wir die Adresse per DNS-over-HTTPS (Cloudflare, dann Google) ab und verbinden uns
direkt zur IP (Host-Header + SNI = Hostname, Zertifikat wird normal geprüft).
"""
from __future__ import annotations
import socket
from urllib.parse import urlsplit
import httpx
def _is_dns_error(exc: BaseException) -> bool:
seen = set()
while exc is not None and id(exc) not in seen:
seen.add(id(exc))
if isinstance(exc, socket.gaierror):
return True
exc = exc.__cause__ or exc.__context__
return False
def is_dns_failure_text(msg: str) -> bool:
m = msg.lower()
return "name or service not known" in m or "temporary failure in name resolution" in m or "nodename nor servname" in m or "gaierror" in m
def doh_resolve(host: str, timeout: float = 5.0) -> list[str]:
for url in ("https://cloudflare-dns.com/dns-query", "https://dns.google/resolve"):
try:
r = httpx.get(url, params={"name": host, "type": "A"}, headers={"accept": "application/dns-json"}, timeout=timeout)
ips = [a["data"] for a in r.json().get("Answer", []) if a.get("type") == 1]
if ips:
return ips
except Exception: # noqa: BLE001
continue
return []
def get_json(url: str, timeout: float = 8.0) -> tuple[int, dict]:
"""GET mit DNS-Rückfall → (status, json-dict oder {}). Bei Rückfall enthält das Dict `_dns_fallback: true`."""
def parse(r):
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
try:
return parse(httpx.get(url, timeout=timeout, follow_redirects=True))
except httpx.ConnectError as exc:
if not _is_dns_error(exc):
raise
parts = urlsplit(url)
for ip in doh_resolve(parts.hostname or ""):
try:
r = httpx.get(f"{parts.scheme}://{ip}{parts.path or '/'}", headers={"Host": parts.hostname}, extensions={"sni_hostname": parts.hostname},
timeout=timeout, follow_redirects=True)
status, body = parse(r)
return status, {**body, "_dns_fallback": True}
except Exception: # noqa: BLE001
continue
raise

View File

@@ -62,7 +62,7 @@ class OnyvaLogin:
@staticmethod @staticmethod
def _real(method, url, headers, body): def _real(method, url, headers, body):
r = httpx.request(method, url, headers=headers, json=body, timeout=30) r = httpx.request(method, url, headers=headers, json=body, timeout=30, follow_redirects=True)
try: try:
data = r.json() if r.content else None data = r.json() if r.content else None
except ValueError: except ValueError:

View File

@@ -80,8 +80,11 @@ def run(tenant_id: str, *, mw_http=None, oidc_client=None, self_get=None) -> dic
# Secrets / OIDC / STT # Secrets / OIDC / STT
will_create = oc["mode"] == "managed" and onyva_login.key_present() and not has_oidc_secrets will_create = oc["mode"] == "managed" and onyva_login.key_present() and not has_oidc_secrets
real_missing = [m for m in missing if not (will_create and m in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))] real_missing = [m for m in missing if not (will_create and m in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))]
hint = "Tenant → Secrets (tenantspezifisch) bzw. Einstellungen → Geteilte Secrets (z. B. CORTECS_API_KEY)."
if draft:
hint += " Geprüft wird der VERÖFFENTLICHTE Stand – Änderungen im Entwurf (z. B. STT-Provider) erst veröffentlichen."
add("secrets", "ok" if not real_missing else "fail", "Pflicht-Secrets", "vollständig" if not real_missing else "fehlen: " + ", ".join(real_missing), add("secrets", "ok" if not real_missing else "fail", "Pflicht-Secrets", "vollständig" if not real_missing else "fehlen: " + ", ".join(real_missing),
"" if not real_missing else "Tenant → Secrets (tenantspezifisch) bzw. Einstellungen → Geteilte Secrets (z. B. CORTECS_API_KEY).") "" if not real_missing else hint)
if will_create: if will_create:
add("oidcnew", "info", "OIDC-Client", "wird beim Deploy automatisch im Login angelegt (Client-ID/-Secret sofort gespeichert)") add("oidcnew", "info", "OIDC-Client", "wird beim Deploy automatisch im Login angelegt (Client-ID/-Secret sofort gespeichert)")
elif oc["mode"] != "off" and not onyva_login.key_present(): elif oc["mode"] != "off" and not onyva_login.key_present():
@@ -92,7 +95,7 @@ def run(tenant_id: str, *, mw_http=None, oidc_client=None, self_get=None) -> dic
add("members", "warn", "Freigaben", "auth_mode „required“, aber keine Nutzer gepflegt – niemand kann sich anmelden", add("members", "warn", "Freigaben", "auth_mode „required“, aber keine Nutzer gepflegt – niemand kann sich anmelden",
"Domain & Login → Freigaben: mindestens dich eintragen (Nutzer muss im Login existieren).") "Domain & Login → Freigaben: mindestens dich eintragen (Nutzer muss im Login existieren).")
if stt_provider != "cortecs": if stt_provider != "cortecs":
add("stt", "info", "STT-Provider", stt_provider + " – braucht MISTRAL_API_KEY") add("stt", "info", "STT-Provider (veröffentlichter Stand)", stt_provider + " – braucht MISTRAL_API_KEY" + (" · im Entwurf evtl. schon geändert, aber noch nicht veröffentlicht" if draft else ""))
# Inhalt # Inhalt
with session() as db: with session() as db:
t = db.get(Tenant, tenant_id) t = db.get(Tenant, tenant_id)

View File

@@ -47,7 +47,7 @@ def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
from app.agent.loop import WORKSPACES from app.agent.loop import WORKSPACES
if source[3:] not in WORKSPACES: if source[3:] not in WORKSPACES:
raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.") raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.")
cfg, files = WORKSPACES[source[3:]] cfg, files, _sample = WORKSPACES[source[3:]]
return dict(cfg), dict(files) return dict(cfg), dict(files)
if source.startswith("proposal:"): if source.startswith("proposal:"):
from app.agent.loop import proposal_content from app.agent.loop import proposal_content
@@ -59,9 +59,22 @@ def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
return store.content(db, t, source) return store.content(db, t, source)
def resolve_sample(db: Session, t: Tenant, source: str) -> str | None:
"""Beispielprotokoll-Override einer Agent-Quelle (None = das des Tenants)."""
if source.startswith("ws:"):
from app.agent.loop import WORKSPACES
w = WORKSPACES.get(source[3:])
return w[2] if w else None
if source.startswith("proposal:"):
from app.models import ChatMessage
m = db.get(ChatMessage, int(source[9:] or 0))
return (m.proposal or {}).get("sample") if m and m.tenant_id == t.id else None
return None
def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict: def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict:
cfg, files = resolve_content(db, t, source) cfg, files = resolve_content(db, t, source)
return run_content(db, t, cfg, files, kind, sample, args) return run_content(db, t, cfg, files, kind, sample if sample is not None else resolve_sample(db, t, source), args)
def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None, def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None,

View File

@@ -15,9 +15,8 @@ TTL = 30.0
def _http_get(url: str): def _http_get(url: str):
import httpx from app.services import netcheck
r = httpx.get(url, timeout=6, follow_redirects=True) return netcheck.get_json(url, timeout=6)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
def probe(host: str, expect_tag: str, expect_bundle: int | None, get=_http_get) -> dict: def probe(host: str, expect_tag: str, expect_bundle: int | None, get=_http_get) -> dict:

View File

@@ -47,14 +47,18 @@ def create(db: Session, tenant_id: str, name: str, clone_from: str | None, actor
def secret_rows(db: Session, tenant_id: str) -> list[dict]: def secret_rows(db: Session, tenant_id: str) -> list[dict]:
from app.services import contract
t = db.get(Tenant, tenant_id)
cfg, _ = store.content(db, t, "current") if t else ({}, {})
required = [k for k in contract.required_secrets(cfg) if k not in ("CORTECS_API_KEY",)] # geteilte Secrets stehen unter Einstellungen
rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))} rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))}
keys = list(dict.fromkeys(defaults.REQUIRED_SECRETS + [k for k in rows if k not in defaults.REQUIRED_SECRETS])) keys = list(dict.fromkeys(required + [k for k in rows if k not in required]))
out = [] out = []
for k in keys: for k in keys:
r = rows.get(k) r = rows.get(k)
out.append({ out.append({
"key": k, "set": bool(r and r.value_enc), "source": r.source if r else "", "key": k, "set": bool(r and r.value_enc), "source": r.source if r else "",
"required": k in defaults.REQUIRED_SECRETS, "help": defaults.SECRET_HELP.get(k, ""), "required": k in required, "help": defaults.SECRET_HELP.get(k, ""),
"updated_at": r.updated_at.isoformat() if r and r.updated_at else None, "updated_at": r.updated_at.isoformat() if r and r.updated_at else None,
}) })
return out return out

View File

@@ -67,12 +67,16 @@ function layout(){
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode)); document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
if(ui.open) refreshPreview(); if(ui.open) refreshPreview();
} }
$('#h-panel').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); }); $('#h-panel').addEventListener('click', e => {
function renderSection(){ const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p;
renderNav(); layout(); if(ui.mode === 'edit') renderSection(); // Formular NEU aufbauen: der Agent kann den Entwurf inzwischen geändert haben (sonst alte Werte sichtbar)
else { renderNav(); layout(); $('#chat-text').focus(); }
});
function renderSectionContent(){
({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv, ({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv,
domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec](); domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec]();
} }
function renderSection(){ renderNav(); layout(); renderSectionContent(); }
/* ───────── Speichern (Entwurf) ───────── */ /* ───────── Speichern (Entwurf) ───────── */
const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; }; const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; };
@@ -427,12 +431,12 @@ async function sendChat(text){
let i; while((i = buf.indexOf('\n\n')) >= 0){ let i; while((i = buf.indexOf('\n\n')) >= 0){
const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2); const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2);
if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); } if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); }
else if(ev.type==='changed'){ refreshPreviewLive(); } else if(ev.type==='changed'){ showPdfIfRelevant(ev.paths); refreshPreviewLive(); }
else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev])); else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev]));
else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `<div class="thought">${esc(ev.text)}</div>`); else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `<div class="thought">${esc(ev.text)}</div>`);
else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text; else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text;
else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message)); else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message));
if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); } if(ev.message.proposal){ showPdfIfRelevant((ev.message.proposal.items||[]).map(i=>i.path)); ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); }
refreshPreview(true); } refreshPreview(true); }
box.scrollTop = 1e9; box.scrollTop = 1e9;
} }
@@ -440,10 +444,15 @@ async function sendChat(text){
} catch(e){ live.remove(); toast(e.message, 1); } } catch(e){ live.remove(); toast(e.message, 1); }
$('#chat-send').disabled = false; $('#chat-send').disabled = false;
} }
const PDF_PATH = /^(protocol\.|pdf_|office\.|preview_sample)/;
function showPdfIfRelevant(paths){
if(!(paths||[]).some(p => PDF_PATH.test(p))) return;
if(ui.pvKind !== 'pdf'){ ui.pvKind = 'pdf'; document.querySelectorAll('#pv-kind button').forEach(b => b.classList.toggle('on', b.dataset.k === 'pdf')); }
}
async function decide(id, d){ async function decide(id, d){
const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m)); const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m));
ui.agentSrc = null; setPvSrc('draft'); ui.agentSrc = null; setPvSrc('draft');
if(d==='apply'){ await load(true); renderNav(); headLogo(); if(ui.sec==='overview') sOverview(); toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); } if(d==='apply'){ await load(true); renderNav(); headLogo(); try{ renderSectionContent(); }catch(e){} toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); }
refreshPreview(true); refreshPreview(true);
} }
async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede zum Entwurf.</div>'; modal('m-diff', true); } async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede zum Entwurf.</div>'; modal('m-diff', true); }
@@ -565,9 +574,9 @@ async function sDeploy(keepConsole){
<span class="badge ${plan.oidc.mode==='off'?'':plan.oidc.key?'ok':'warn'}" title="Wird bei jedem Deploy vorher mit der Login-API abgeglichen">OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}</span></dd></dl> <span class="badge ${plan.oidc.mode==='off'?'':plan.oidc.key?'ok':'warn'}" title="Wird bei jedem Deploy vorher mit der Login-API abgeglichen">OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}</span></dd></dl>
${plan.missing_secrets.length?`<div class="banner warn" style="margin-top:8px">Secrets ohne Wert: <span class="mono">${esc(plan.missing_secrets.join(', '))}</span></div>`:''} ${plan.missing_secrets.length?`<div class="banner warn" style="margin-top:8px">Secrets ohne Wert: <span class="mono">${esc(plan.missing_secrets.join(', '))}</span></div>`:''}
<details class="fold"><summary>Service-Definition (PATCH-Body, Secrets maskiert)</summary><div class="in"><pre class="json">${esc(JSON.stringify(plan.patch,null,2))}</pre></div></details> <details class="fold"><summary>Service-Definition (PATCH-Body, Secrets maskiert)</summary><div class="in"><pre class="json">${esc(JSON.stringify(plan.patch,null,2))}</pre></div></details>
<div class="row" style="margin-top:10px"><button class="btn" onclick="startDeploy(true)">Dry-Run</button><button class="btn danger ${m.live?'':'disabled'}" onclick="rollbackTenant()" title="Auf den vorherigen erfolgreich deployten Core-Tag zurück">Zurückrollen</button><button class="btn cyan ${m.live&&m.tenant_allowed?'':'disabled'}" onclick="startDeploy(false)" title="${!m.live?'Gesperrt: DEPLOY_LIVE nicht aktiv':!m.tenant_allowed?'Gesperrt: Tenant nicht in DEPLOY_ALLOWED_TENANTS':''}">Deployen</button></div> <div class="row" style="margin-top:10px"><button class="btn" onclick="startDeploy(true)">Dry-Run</button><button class="btn" onclick="recheckHealth()" title="Prüft https://<Host>/health erneut – z. B. wenn das Zertifikat bei Mittwald inzwischen fertig ist">Health erneut prüfen</button><button class="btn danger ${m.live?'':'disabled'}" onclick="rollbackTenant()" title="Auf den vorherigen erfolgreich deployten Core-Tag zurück">Zurückrollen</button><button class="btn cyan ${m.live&&m.tenant_allowed?'':'disabled'}" onclick="startDeploy(false)" title="${!m.live?'Gesperrt: DEPLOY_LIVE nicht aktiv':!m.tenant_allowed?'Gesperrt: Tenant nicht in DEPLOY_ALLOWED_TENANTS':''}">Deployen</button></div>
<div class="console hide" id="con" style="margin-top:10px"></div></div> <div class="console hide" id="con" style="margin-top:10px"></div></div>
<div class="card" style="margin-top:12px"><h3>Verlauf</h3>${mine.length?`<table class="t"><thead><tr><th>Zeit</th><th>Art</th><th>Version</th><th>Status</th></tr></thead><tbody>${mine.map(d=>`<tr><td>${fmt(d.created_at)}</td><td>${kindLabel[d.kind]||d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version}</td><td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':''}">${d.status}</span></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Noch keine Deployments für diesen Tenant.</div>'}</div>`; <div class="card" style="margin-top:12px"><h3>Verlauf</h3>${mine.length?`<table class="t"><thead><tr><th>Zeit</th><th>Art</th><th>Version</th><th>Status</th></tr></thead><tbody>${mine.map(d=>`<tr><td>${fmt(d.created_at)}</td><td>${kindLabel[d.kind]||d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version}</td><td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='warning'?'warn':''}">${d.status}</span></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Noch keine Deployments für diesen Tenant.</div>'}</div>`;
if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; } if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; }
} }
async function runPreflight(){ async function runPreflight(){
@@ -579,6 +588,10 @@ async function runPreflight(){
r.items.map(i=>`<div class="check"><span class="dot ${dot[i.level]}" ${i.level==='warn'?'style="background:#fbf0cf;color:#8a6200"':''}>${ic[i.level]}</span><span><b>${esc(i.label)}</b> <span class="muted">${esc(i.detail)}</span>${i.fix?`<div class="hint">→ ${esc(i.fix)}</div>`:''}</span></div>`).join(''); r.items.map(i=>`<div class="check"><span class="dot ${dot[i.level]}" ${i.level==='warn'?'style="background:#fbf0cf;color:#8a6200"':''}>${ic[i.level]}</span><span><b>${esc(i.label)}</b> <span class="muted">${esc(i.detail)}</span>${i.fix?`<div class="hint">→ ${esc(i.fix)}</div>`:''}</span></div>`).join('');
}catch(e){ $('#pf').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; } }catch(e){ $('#pf').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
} }
async function recheckHealth(){
const con = $('#con'); con.classList.remove('hide'); con.style.display='block'; con.textContent = 'Prüfe https://' + S.host + '/health … (bis ca. 40 s)';
try{ const r = await api('POST', A('/health-check')); con.innerHTML = esc(r.log.join('\n')) + (r.ok ? '\n✔ Health ok' : ''); if(r.ok){ await load(true); sDeploy(true); } }catch(e){ con.textContent = e.message; }
}
async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } } async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } }
async function startDeploy(dry){ async function startDeploy(dry){
if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return; if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return;

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Onyscribe Admin{% endblock %}</title> <title>{% block title %}Onyscribe Admin{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231a1a1a'/%3E%3Cpath d='M20 22l14 10-14 10' fill='none' stroke='%2300cccc' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231a1a1a'/%3E%3Cpath d='M20 22l14 10-14 10' fill='none' stroke='%2300cccc' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/admin.css"> <link rel="stylesheet" href="/static/admin.css?v={{ static_v('admin.css') }}">
</head> </head>
<body> <body>
<aside class="side"> <aside class="side">

View File

@@ -80,7 +80,7 @@ function followBatch(batch, title){
document.getElementById('roll-title').textContent = title; document.getElementById('roll-hint').textContent = 'Reihenfolge: Canary zuerst, dann der Rest; Abbruch beim ersten Fehler.'; modal('m-roll', true); document.getElementById('roll-title').textContent = title; document.getElementById('roll-hint').textContent = 'Reihenfolge: Canary zuerst, dann der Rest; Abbruch beim ersten Fehler.'; modal('m-roll', true);
const tick = async () => { const tick = async () => {
const rows = await api('GET', '/api/deployments?batch=' + batch); const rows = await api('GET', '/api/deployments?batch=' + batch);
document.querySelector('#roll-table tbody').innerHTML = rows.map(d=>{ const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''; return `<tr><td><b>${esc(d.tenant)}</b></td><td class="mono">:${esc(String(d.image).slice(0,10))}</td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`; }).join(''); document.querySelector('#roll-table tbody').innerHTML = rows.map(d=>{ const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='warning'?'warn':d.status==='running'?'cyan':''; return `<tr><td><b>${esc(d.tenant)}</b></td><td class="mono">:${esc(String(d.image).slice(0,10))}</td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`; }).join('');
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick, 800); if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick, 800);
}; tick(); }; tick();
} }

View File

@@ -50,7 +50,7 @@
<div class="card" style="margin-top:12px"><h3>Letzte Deployments <a href="/deployments" class="muted" style="font-weight:400;font-size:12px">· alle ansehen</a></h3> <div class="card" style="margin-top:12px"><h3>Letzte Deployments <a href="/deployments" class="muted" style="font-weight:400;font-size:12px">· alle ansehen</a></h3>
{% if last %}<table class="t"><tbody>{% for d in last %}<tr><td class="nowrap">{{ d.created_at|dt }}</td><td><b>{{ d.tenant_id or 'alle' }}</b></td><td>{{ d.kind }}</td><td>v{{ d.version }}</td> {% if last %}<table class="t"><tbody>{% for d in last %}<tr><td class="nowrap">{{ d.created_at|dt }}</td><td><b>{{ d.tenant_id or 'alle' }}</b></td><td>{{ d.kind }}</td><td>v{{ d.version }}</td>
<td><span class="badge {{ 'ok' if d.status=='success' else 'err' if d.status in ('failed',) else '' }}">{{ d.status }}</span></td></tr>{% endfor %}</tbody></table> <td><span class="badge {{ 'ok' if d.status=='success' else 'warn' if d.status=='warning' else 'err' if d.status in ('failed',) else '' }}">{{ d.status }}</span></td></tr>{% endfor %}</tbody></table>
{% else %}<div class="empty">Noch keine Deployments.</div>{% endif %} {% else %}<div class="empty">Noch keine Deployments.</div>{% endif %}
</div> </div>
</div> </div>
@@ -114,7 +114,7 @@ async function deployAll(dry){
const rows=await api('GET','/api/deployments?batch='+r.batch); const rows=await api('GET','/api/deployments?batch='+r.batch);
document.querySelector('#all-table tbody').innerHTML=rows.map(d=>{ document.querySelector('#all-table tbody').innerHTML=rows.map(d=>{
const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10);
const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''; const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='warning'?'warn':d.status==='running'?'cyan':'';
return `<tr><td><b>${esc(d.tenant)}</b></td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`}).join(''); return `<tr><td><b>${esc(d.tenant)}</b></td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`}).join('');
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick,700); if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick,700);
};tick(); };tick();

View File

@@ -20,7 +20,7 @@ async function load(){
const rows=await api('GET','/api/deployments'); const rows=await api('GET','/api/deployments');
document.getElementById('list').innerHTML=rows.length?`<table class="t"><thead><tr><th>#</th><th>Zeit</th><th>Tenant</th><th>Art</th><th>Version</th><th>Image</th><th>Status</th><th></th></tr></thead><tbody>`+rows.map(d=>` document.getElementById('list').innerHTML=rows.length?`<table class="t"><thead><tr><th>#</th><th>Zeit</th><th>Tenant</th><th>Art</th><th>Version</th><th>Image</th><th>Status</th><th></th></tr></thead><tbody>`+rows.map(d=>`
<tr><td>${d.id}</td><td class="nowrap">${fmt(d.created_at)}</td><td><b>${esc(d.tenant||'alle')}</b></td><td>${d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version??'–'}</td><td class="mono">:${esc(d.image)}</td> <tr><td>${d.id}</td><td class="nowrap">${fmt(d.created_at)}</td><td><b>${esc(d.tenant||'alle')}</b></td><td>${d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version??'–'}</td><td class="mono">:${esc(d.image)}</td>
<td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''}">${d.status}</span></td><td><button class="btn sm" onclick="tg(${d.id})">Log</button></td></tr> <td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='warning'?'warn':d.status==='running'?'cyan':''}">${d.status}</span></td><td><button class="btn sm" onclick="tg(${d.id})">Log</button></td></tr>
<tr style="display:${open_[d.id]?'table-row':'none'}"><td colspan="8"><div class="console">${esc(d.log||'(leer)')}</div></td></tr>`).join('')+'</tbody></table>':'<div class="empty">Noch keine Deployments.</div>'; <tr style="display:${open_[d.id]?'table-row':'none'}"><td colspan="8"><div class="console">${esc(d.log||'(leer)')}</div></td></tr>`).join('')+'</tbody></table>':'<div class="empty">Noch keine Deployments.</div>';
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(load,900); if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(load,900);
} }

View File

@@ -47,5 +47,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script>const TID={{ t.id|tojson }};</script> <script>const TID={{ t.id|tojson }};</script>
<script src="/static/tenant.js"></script> <script src="/static/tenant.js?v={{ static_v('tenant.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -30,3 +30,14 @@ def _live() -> bool:
templates.env.globals["deploy_live"] = _live templates.env.globals["deploy_live"] = _live
def _static_v(name: str) -> str:
"""Cache-Busting: Änderungszeit der Datei als Query-Parameter, damit Browser nach einem Deploy nie veraltete JS/CSS-Dateien nutzen."""
try:
return str(int((ROOT / "app" / "static" / name).stat().st_mtime))
except OSError:
return "0"
templates.env.globals["static_v"] = _static_v

107
tests/test_agent_preview.py Normal file
View File

@@ -0,0 +1,107 @@
"""Änderungen des KI-Agenten müssen in Vorschau (live, auch PDF) und nach der Freigabe sichtbar sein."""
import io
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pypdf import PdfReader
from app.agent import loop
from app.agent.backends import Turn
from app.db import session
from app.main import app
from app.models import ChatMessage, Tenant
from app.services import preview
from app.services.importer import import_all
from app.settings import LEGACY_PLATFORM
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
with session() as db:
import_all(db, Path(LEGACY_PLATFORM))
yield c
def pdf_text(b: bytes) -> str:
return "\n".join(p.extract_text() or "" for p in PdfReader(io.BytesIO(b)).pages)
class Scripted:
name, model = "scripted", "x"
def __init__(self, *calls):
self.calls, self.i = calls, 0
def step(self, system, messages, tools):
self.i += 1
if self.i == 1:
return Turn([{"type": "tool_use", "id": str(n), "name": nm, "input": a} for n, (nm, a) in enumerate(self.calls)])
return Turn([{"type": "text", "text": "Fertig."}])
CSS = "body { background: #ffee99; } h2 { color: #cc0000; }"
SAMPLE = "===TERMINART===\nWorkshop\n===/TERMINART===\n===PROJEKT===\nAgentenprojekt\n===/PROJEKT===\nAnlass & Kontext\nEin Text nur aus dem Agenten-Beispiel.\n"
def test_live_preview_sees_files_written_by_the_running_agent(client):
"""Regression: Dateien lagen in der offenen Transaktion des Agenten, die Vorschau (andere Sitzung) bekam KeyError."""
with session() as db:
t = db.get(Tenant, "sprinz")
base = preview.run(db, t, "draft", "pdf")
events = []
for ev in loop.run(db, t, "x", backend=Scripted(("write_file", {"path": "protocol.css", "content": CSS}), ("set_config", {"key": "pdf_projekt_label", "value": "AGENTENTHEMA"}))):
events.append(ev)
if ev["type"] == "changed":
with session() as other: # wie der Preview-Request: eigene Sitzung, Agent-Transaktion läuft noch
live = preview.run(other, other.get(Tenant, "sprinz"), f"ws:{ev['run']}", "pdf")
assert live != base # kein KeyError, und die Änderung ist im PDF
changed = [e for e in events if e["type"] == "changed"]
assert "protocol.css" in changed[0]["paths"] and any("pdf_projekt_label" in p for p in changed[-1]["paths"])
def test_sample_set_by_agent_reaches_live_preview_proposal_and_draft(client):
with session() as db:
t = db.get(Tenant, "revier")
events = list(loop.run(db, t, "x", backend=Scripted(("set_sample", {"text": SAMPLE}))))
db.commit()
run = next(e["run"] for e in events if e["type"] == "mode")
prop = next(e for e in events if e["type"] == "proposal")["proposal"]
assert prop["sample"] == SAMPLE and any(i["path"] == "Vorschau-Beispielprotokoll" for i in prop["items"])
assert "Agentenprojekt" in pdf_text(preview.run(db, t, f"ws:{run}", "pdf")) # live
assert "Agentenprojekt" not in pdf_text(preview.run(db, t, "draft", "pdf")) # Entwurf unberührt
m = ChatMessage(tenant_id="revier", role="assistant", content="ok", proposal=prop, proposal_status="open")
db.add(m)
db.commit()
assert "Agentenprojekt" in pdf_text(preview.run(db, t, f"proposal:{m.id}", "pdf")) # Vorschlag
loop.apply_proposal(db, t, prop)
db.commit()
assert "Agentenprojekt" in pdf_text(preview.run(db, t, "draft", "pdf")) # nach Freigabe im Entwurf
def test_prompt_test_result_becomes_the_pdf_sample(client, monkeypatch):
from app.services import llm
llm_text = SAMPLE.replace("Agentenprojekt", "LLMERGEBNIS")
monkeypatch.setattr(llm, "summarize", lambda db, system, user, model=None, tenant_id=None: {"text": llm_text, "model": "m", "input_tokens": 1, "output_tokens": 1, "truncated": False})
with session() as db:
t = db.get(Tenant, "demo")
events = list(loop.run(db, t, "x", backend=Scripted(("run_prompt_test", {}))))
run = next(e["run"] for e in events if e["type"] == "mode")
assert "LLMERGEBNIS" in pdf_text(preview.run(db, t, f"ws:{run}", "pdf"))
assert next(e for e in events if e["type"] == "proposal")["proposal"]["sample"] == llm_text
def test_api_flow_through_sse_and_apply(client, monkeypatch):
"""Über die echte API: Agent schreibt CSS + Beispiel → Vorschlag → Freigabe → Entwurf-PDF zeigt es."""
monkeypatch.setattr(loop, "make_backend", lambda db, tid=None: Scripted(("write_file", {"path": "protocol.css", "content": CSS}), ("set_sample", {"text": SAMPLE})))
r = client.post("/api/tenants/onyva/chat", json={"message": "mach was"})
import json
ev = [json.loads(l[6:]) for l in r.text.split("\n\n") if l.startswith("data: ")]
assert any(e["type"] == "changed" and "protocol.css" in e["paths"] for e in ev)
msg = ev[-1]["message"]
live = client.get(f"/preview/onyva/pdf?source=proposal:{msg['id']}")
assert live.status_code == 200 and "Agentenprojekt" in pdf_text(live.content)
assert client.post(f"/api/tenants/onyva/chat/{msg['id']}/apply").status_code == 200
assert "Agentenprojekt" in pdf_text(client.get("/preview/onyva/pdf?source=draft").content)
assert client.get("/api/tenants/onyva/state").json()["files"]["protocol.css"]

View File

@@ -85,7 +85,8 @@ def test_required_secrets_follow_schema_conditions():
assert "MISTRAL_API_KEY" not in contract.required_secrets({"stt": {"provider": "cortecs"}}) assert "MISTRAL_API_KEY" not in contract.required_secrets({"stt": {"provider": "cortecs"}})
assert "MISTRAL_API_KEY" in contract.required_secrets({"stt": {"provider": "mistral"}}) assert "MISTRAL_API_KEY" in contract.required_secrets({"stt": {"provider": "mistral"}})
assert "MISTRAL_API_KEY" in contract.required_secrets({}) assert "MISTRAL_API_KEY" in contract.required_secrets({})
assert {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY", "CORTECS_API_KEY"} <= set(contract.required_secrets({})) assert {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "CORTECS_API_KEY"} <= set(contract.required_secrets({}))
assert "COST_INGEST_KEY" not in contract.required_secrets({}) # optional: ohne Key bleibt die Kostenerfassung inaktiv
# ── Health-Verifikation & Zustandsabgleich ────────────────────────────────── # ── Health-Verifikation & Zustandsabgleich ──────────────────────────────────
@@ -492,3 +493,120 @@ def test_cortecs_key_from_admin_env_counts_as_set_and_reaches_the_container(clie
assert deploy.build_env(db, t, reveal=True)["CORTECS_API_KEY"] == "ck-aus-env" assert deploy.build_env(db, t, reveal=True)["CORTECS_API_KEY"] == "ck-aus-env"
assert "ck-aus-env" not in str(deploy.build_env(db, t, reveal=False)) assert "ck-aus-env" not in str(deploy.build_env(db, t, reveal=False))
assert "aus Admin-Env" in deploy.build_env(db, t, reveal=False)["CORTECS_API_KEY"] assert "aus Admin-Env" in deploy.build_env(db, t, reveal=False)["CORTECS_API_KEY"]
def test_http_clients_follow_redirects_and_3xx_is_never_success(monkeypatch):
import httpx
from app.services import mittwald as mwmod, onyva_login
from app.services.mittwald import DeployError, Mittwald
seen = []
class R:
status_code, content, text, headers = 200, b"[]", "[]", {}
def json(self): return []
monkeypatch.setattr(httpx, "request", lambda *a, **k: (seen.append(k), R())[1])
Mittwald._real_http("GET", "https://x/v2/stacks/", {}, None)
onyva_login.OnyvaLogin._real("GET", "https://x/api/v1/apps", {}, None)
assert all(k.get("follow_redirects") is True for k in seen) and len(seen) == 2
# Eine nicht gefolgte 308-Weiterleitung (leerer Body) ist ein Fehler, kein „null“
with pytest.raises(DeployError, match="308"):
Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (308, None, "")).api("GET", "/projects/p/stacks/", write=False)
# ── Health-Check bei neuem Ingress (Zertifikat/DNS dauert) ──────────────────
def test_verify_health_distinguishes_unreachable_from_wrong_version():
from app.services.deploy import HealthTimeout
with pytest.raises(HealthTimeout) as e1:
deploy.verify_health("h", "abcdef1", None, lambda m: None, get=lambda u: (_ for _ in ()).throw(OSError("ssl handshake")), tries=2, delay=0, sleep=lambda s: None)
assert e1.value.answered is False
with pytest.raises(HealthTimeout) as e2:
deploy.verify_health("h", "abcdef1", None, lambda m: None, get=lambda u: (502, {}), tries=2, delay=0, sleep=lambda s: None)
assert e2.value.answered is False # Proxy antwortet, App nicht
with pytest.raises(HealthTimeout) as e3:
deploy.verify_health("h", "abcdef1", None, lambda m: None, get=lambda u: (200, health_body("9999999aaaa")), tries=1)
assert e3.value.answered is True
def test_new_ingress_with_slow_certificate_ends_with_warning_not_failure(client, live_env):
from app.services.deploy import HealthTimeout
_ready(client, "hc1")
mw = FakeMW() # GET /ingresses → [] ⇒ Ingress wird neu angelegt
never = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("keine Antwort", answered=False)) # noqa: E731
dep = deploy.start("hc1", "deploy", "t", http=mw, health=never, wait=True)
d = client.get(f"/api/deployments/{dep}").json()
assert d["status"] == "warning" and "Zertifikat/DNS" in d["log"] and "Fertig mit Warnung" in d["log"]
with session() as db:
t = db.get(Tenant, "hc1")
assert t.deployed_version == t.current_version and t.deployed_image # Zustand wurde trotzdem festgehalten
# Zertifikat inzwischen da → nachträglicher Check macht den Eintrag grün
r = deploy.recheck_health("hc1", health=lambda *a: None)
assert r["ok"] and client.get(f"/api/deployments/{dep}").json()["status"] == "success"
def test_wrong_version_or_existing_ingress_still_fails(client, live_env):
from app.services.deploy import HealthTimeout
_ready(client, "hc2")
mw = FakeMW()
wrong = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("Core falsch", answered=True)) # noqa: E731
d = client.get(f"/api/deployments/{deploy.start('hc2', 'deploy', 't', http=mw, health=wrong, wait=True)}").json()
assert d["status"] == "failed" # neuer Ingress, aber echte Antwort mit falscher Version
# bestehender Ingress + keine Antwort ⇒ echter Fehler
class WithIngress(FakeMW):
def __call__(self, method, url, headers, body):
if method == "GET" and "ingresses" in url:
return 200, [{"hostname": "hc2.onyscribe.de", "id": "i1"}], ""
return super().__call__(method, url, headers, body)
never = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("keine Antwort", answered=False)) # noqa: E731
d2 = client.get(f"/api/deployments/{deploy.start('hc2', 'deploy', 't', http=WithIngress(), health=never, wait=True)}").json()
assert d2["status"] == "failed"
# ── DNS-Rückfall beim Health-Check ──────────────────────────────────────────
def test_dns_failure_falls_back_to_doh_and_connects_by_ip(monkeypatch):
import socket
import httpx
from app.services import netcheck
calls = []
class Resp:
status_code, headers = 200, {"content-type": "application/json"}
def __init__(self, body): self._b = body
def json(self): return self._b
def fake_get(url, **kw):
calls.append((url, kw.get("headers"), kw.get("extensions")))
if url == "https://demo.example.de/health":
raise httpx.ConnectError("[Errno -2] Name or service not known") from socket.gaierror(-2, "Name or service not known")
if "dns-query" in url:
return Resp({"Answer": [{"type": 1, "data": "203.0.113.7"}]})
return Resp({"status": "ok", "core": {"version": "abcdef1234567"}})
monkeypatch.setattr(httpx, "get", fake_get)
status, body = netcheck.get_json("https://demo.example.de/health")
assert status == 200 and body["_dns_fallback"] and body["core"]["version"].startswith("abcdef1")
ip_call = calls[-1]
assert ip_call[0] == "https://203.0.113.7/health" and ip_call[1] == {"Host": "demo.example.de"} and ip_call[2] == {"sni_hostname": "demo.example.de"}
# Verbindungsfehler ohne DNS-Ursache werden NICHT umgangen
monkeypatch.setattr(httpx, "get", lambda url, **kw: (_ for _ in ()).throw(httpx.ConnectError("refused")))
with pytest.raises(httpx.ConnectError):
netcheck.get_json("https://demo.example.de/health")
def test_unresolvable_host_is_a_warning_for_single_deploys_but_fails_strict_rollouts(client, live_env):
from app.services.deploy import HealthTimeout
_ready(client, "dn1")
class WithIngress(FakeMW):
def __call__(self, method, url, headers, body):
if method == "GET" and "ingresses" in url:
return 200, [{"hostname": "dn1.onyscribe.de", "id": "i1"}], ""
return super().__call__(method, url, headers, body)
dns = lambda *a, **k: (_ for _ in ()).throw(HealthTimeout("Name or service not known", answered=False, dns=True)) # noqa: E731
d = client.get(f"/api/deployments/{deploy.start('dn1', 'deploy', 't', http=WithIngress(), health=dns, wait=True)}").json()
assert d["status"] == "warning" and "kann den Hostnamen nicht auflösen" in d["log"] # bestehender Ingress, aber nur DNS-Problem des Admin-Servers
# strikt (Core-Rollout): ohne neuen Ingress bleibt es ein Fehler
rid = _approved_release(client, "5" * 40)
batch, _ = releases.rollout(rid, "t", dry_run=False, http=WithIngress(), health=dns, wait=True)
assert [r["status"] for r in client.get(f"/api/deployments?batch={batch}").json() if r["tenant"] == "dn1"] == ["failed"]

View File

@@ -477,9 +477,8 @@ def test_deploy_runs_oidc_sync_before_service_patch(client, login, monkeypatch):
# Pflicht-Secrets laut Core-Schema: erst fehlen sie → Deploy wird VOR dem Service-Eingriff blockiert # Pflicht-Secrets laut Core-Schema: erst fehlen sie → Deploy wird VOR dem Service-Eingriff blockiert
blocked = deploy.start("klinik5", "deploy", "test", http=lambda *a: (200, {}, ""), oidc_client=None, wait=True, health=lambda *a: None) blocked = deploy.start("klinik5", "deploy", "test", http=lambda *a: (200, {}, ""), oidc_client=None, wait=True, health=lambda *a: None)
d0 = client.get(f"/api/deployments/{blocked}").json() d0 = client.get(f"/api/deployments/{blocked}").json()
assert d0["status"] == "failed" and ("COST_INGEST_KEY" in d0["log"] or "OIDC-Abgleich" in d0["log"] or "ONYVA_LOGIN_API_KEY" in d0["log"]) assert d0["status"] == "failed" and "Pflicht-Secrets ohne Wert: CORTECS_API_KEY" in d0["log"]
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"}) client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
client.put("/api/tenants/klinik5/secrets/COST_INGEST_KEY", json={"value": "ik"})
order = [] order = []
stack = {"services": {"legacy": {"id": "svc-0"}, "app-klinik5": {"id": "svc-5"}}} stack = {"services": {"legacy": {"id": "svc-0"}, "app-klinik5": {"id": "svc-5"}}}

View File

@@ -1 +1 @@
13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration d6e72060858f584661bafc6396d40319b11e5afc admin-platform-integration

View File

@@ -1 +1 @@
{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"} {"sha": "d6e72060858f584661bafc6396d40319b11e5afc", "built_at": "2026-10-15T14:36:00Z"}

View File

@@ -813,12 +813,11 @@
"name": "COST_INGEST_KEY", "name": "COST_INGEST_KEY",
"kind": "secret", "kind": "secret",
"scope": "tenant", "scope": "tenant",
"label": "Cost-Ingest-Key", "label": "Cost-Ingest-Key (optional)",
"help": "Schlüssel für die Kostenerfassung.", "help": "Optional – ohne Key bleibt die Kostenerfassung inaktiv (core/cost.py).",
"default": null, "default": null,
"group": "Secrets", "group": "Secrets",
"setting": "cost_ingest_key", "setting": "cost_ingest_key"
"required": true
}, },
{ {
"name": "TENANT", "name": "TENANT",