"""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 | ws: (Live-Arbeitsstand des Agenten) | proposal: (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)