Files
Onyscribe-Admin/app/services/preview.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

110 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Live-Vorschau: führt den vendored Core mit dem (Entwurfs-)Tenant in einem Subprozess aus."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from sqlalchemy.orm import Session
from app.models import Tenant
from app.services import store
from app.services.samples import DEFAULT_SAMPLE
from app.settings import PLATFORM_DIR, PREVIEW_CACHE
WORKER = Path(__file__).with_name("preview_worker.py")
TIMEOUT = 90
class PreviewError(RuntimeError):
pass
def core_version() -> str:
from app.services import contract
return contract.sha()[:10]
def sample_for(t: Tenant) -> str:
return t.preview_sample or DEFAULT_SAMPLE
EXT = {"index": "html", "pdf": "pdf", "validate": "json", "prompt": "json"}
class SourceGone(LookupError):
pass
def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
"""draft | current | v<N> | ws:<run> (Live-Arbeitsstand des Agenten) | proposal:<msg> (Vorschlag des Agenten)."""
if source.startswith("ws:"):
from app.agent.loop import WORKSPACES
if source[3:] not in WORKSPACES:
raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.")
cfg, files = WORKSPACES[source[3:]]
return dict(cfg), dict(files)
if source.startswith("proposal:"):
from app.agent.loop import proposal_content
from app.models import ChatMessage
m = db.get(ChatMessage, int(source[9:] or 0))
if m is None or m.tenant_id != t.id or not m.proposal:
raise SourceGone("Vorschlag nicht gefunden.")
return proposal_content(db, t, m.proposal)
return store.content(db, t, source)
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)
return run_content(db, t, cfg, files, kind, sample, args)
def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None,
args: dict | None = None) -> bytes | dict:
"""Rendert beliebigen Inhalt (cfg + {pfad: sha}) – für Entwurf, Version oder Agent-Workspace."""
sample = sample if sample is not None else sample_for(t)
h = hashlib.sha256(json.dumps([cfg, files, kind, sample, args or {}, core_version()], sort_keys=True).encode())
cached = PREVIEW_CACHE / f"{h.hexdigest()[:32]}.{EXT[kind]}"
if not cached.exists():
_execute(db, t.id, cfg, files, kind, sample, cached, args or {})
data = cached.read_bytes()
return json.loads(data) if EXT[kind] == "json" else data
def _limits() -> None:
import resource
resource.setrlimit(resource.RLIMIT_CPU, (90, 90))
resource.setrlimit(resource.RLIMIT_FSIZE, (200 * 1024 * 1024,) * 2)
os.setsid()
def _execute(db: Session, tenant_id: str, cfg: dict, files: dict, kind: str, sample: str, out: Path, args: dict) -> None:
with tempfile.TemporaryDirectory(prefix="onyscribe-preview-") as tmp_s:
tmp = Path(tmp_s)
shutil.copytree(PLATFORM_DIR / "core", tmp / "core", ignore=shutil.ignore_patterns("__pycache__"))
store.write_tenant_dir(db, cfg, files, tmp / "tenants" / tenant_id)
(tmp / "data").mkdir()
shutil.copy(WORKER, tmp / "preview_worker.py")
env = {
"PATH": os.environ.get("PATH", ""), "HOME": str(tmp), "LANG": "C.UTF-8",
"TENANT": tenant_id, "DATA_DIR": str(tmp / "data"), "AUTH_ENABLED": "false",
"PYTHONPATH": str(tmp), "PYTHONDONTWRITEBYTECODE": "1",
# Kein einziges echtes Secret im Sandbox-Prozess
"OIDC_ISSUER": "https://login.example.invalid", "SESSION_SECRET": "preview",
}
result = tmp / f"result.{out.suffix.lstrip('.')}"
payload = json.dumps({"summary": sample, "title": f"{cfg.get('name', tenant_id)} – Vorschau", **args})
try:
proc = subprocess.run([sys.executable, "-I", "preview_worker.py", kind, str(result)], input=payload,
cwd=tmp, env=env, capture_output=True, text=True, timeout=TIMEOUT, preexec_fn=_limits)
except subprocess.TimeoutExpired as exc:
raise PreviewError("Vorschau-Timeout (Tenant-Code läuft zu lange)") from exc
if proc.returncode != 0 or not result.exists():
raise PreviewError((proc.stderr or proc.stdout or "unbekannter Fehler")[-2500:])
shutil.copy(result, out)