Compare commits
5 Commits
8000f76457
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d3ec5dab | ||
|
|
40b3f687f7 | ||
|
|
9c352be05b | ||
|
|
31a99e31c0 | ||
|
|
f33312369f |
@@ -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]:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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):
|
||||||
@@ -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
60
app/services/netcheck.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
107
tests/test_agent_preview.py
Normal 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"]
|
||||||
@@ -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 ──────────────────────────────────
|
||||||
@@ -501,7 +502,7 @@ def test_http_clients_follow_redirects_and_3xx_is_never_success(monkeypatch):
|
|||||||
seen = []
|
seen = []
|
||||||
|
|
||||||
class R:
|
class R:
|
||||||
status_code, content, text = 200, b"[]", "[]"
|
status_code, content, text, headers = 200, b"[]", "[]", {}
|
||||||
def json(self): return []
|
def json(self): return []
|
||||||
|
|
||||||
monkeypatch.setattr(httpx, "request", lambda *a, **k: (seen.append(k), R())[1])
|
monkeypatch.setattr(httpx, "request", lambda *a, **k: (seen.append(k), R())[1])
|
||||||
@@ -511,3 +512,101 @@ def test_http_clients_follow_redirects_and_3xx_is_never_success(monkeypatch):
|
|||||||
# Eine nicht gefolgte 308-Weiterleitung (leerer Body) ist ein Fehler, kein „null“
|
# Eine nicht gefolgte 308-Weiterleitung (leerer Body) ist ein Fehler, kein „null“
|
||||||
with pytest.raises(DeployError, match="308"):
|
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)
|
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"]
|
||||||
|
|||||||
@@ -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"}}}
|
||||||
|
|
||||||
|
|||||||
2
vendor/platform/CORE_VERSION
vendored
2
vendor/platform/CORE_VERSION
vendored
@@ -1 +1 @@
|
|||||||
13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration
|
d6e72060858f584661bafc6396d40319b11e5afc admin-platform-integration
|
||||||
|
|||||||
2
vendor/platform/core/BUILD_INFO.json
vendored
2
vendor/platform/core/BUILD_INFO.json
vendored
@@ -1 +1 @@
|
|||||||
{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"}
|
{"sha": "d6e72060858f584661bafc6396d40319b11e5afc", "built_at": "2026-10-15T14:36:00Z"}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user