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>
This commit is contained in:
109
app/services/preview.py
Normal file
109
app/services/preview.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user