root f408a555f8 feat: kontinuierliche LXC↔Mac-Diagnose + Auto-Retry
Diagnose-Sichtbarkeit (Plan A):
- Job.step_started_at + last_heartbeat_at, Heartbeat-Task pingt alle 3 s
  während laufender Mac-Calls
- Mac-Worker hält per X-Job-Id Header einen Log-Ringbuffer pro Job
  (200 Zeilen, 1 h TTL); GET /api/jobs/{id}/log liefert ihn aus
- LXC-Endpoint GET /api/jobs/{id}/diag bündelt Job-Status, Stage-Sekunden,
  Heartbeat-Alter und Mac-Worker-Log
- Frontend: Live-Timing im Status-Pill ("läuft seit Xs · letzter Ping vor Ys"),
  klappbares Diagnose-Panel mit Mac-Log für FAILED + langlaufende Jobs

Robustheit (Plan B):
- MacClient: einmaliger Retry mit 3 s Backoff bei RemoteProtocolError /
  ConnectError / ReadError für summarize/protocol/export
- Mac-Worker /api/preload heizt Ollama vor (keep_alive 30 m, num_predict 1);
  Pipeline ruft Preload vor summarize, damit Modell-Reload nicht mitten
  in der Generierung passiert (Hauptursache "Server disconnected")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:12:27 +00:00

71 lines
2.9 KiB
Python

"""Leichte In-Place-Migrationen für SQLite (kein Alembic).
Wird beim App-Start nach create_all() ausgeführt. Idempotent.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlmodel import Session
from app.core.db import engine
log = logging.getLogger("voice-agent.migrate")
def _existing_columns(conn, table: str) -> set[str]:
rows = conn.execute(text(f"PRAGMA table_info({table})")).all()
return {r[1] for r in rows} # row[1] = column name
def run_migrations() -> None:
with engine.begin() as conn:
# 1) Job.owner_id Spalte nachrüsten, falls die Tabelle aus alter Version kommt.
cols = _existing_columns(conn, "job")
if "owner_id" not in cols:
log.info("Migration: füge job.owner_id hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN owner_id INTEGER"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_job_owner_id ON job(owner_id)"))
# 2) Job.profile Spalte nachrüsten (Profil-System).
cols = _existing_columns(conn, "job")
if "profile" not in cols:
log.info("Migration: füge job.profile hinzu (default 'meeting')")
conn.execute(text("ALTER TABLE job ADD COLUMN profile VARCHAR DEFAULT 'meeting'"))
# 3) User.default_profile nachrüsten.
user_cols = _existing_columns(conn, "user")
if user_cols and "default_profile" not in user_cols:
log.info("Migration: füge user.default_profile hinzu (default 'meeting')")
conn.execute(text("ALTER TABLE user ADD COLUMN default_profile VARCHAR DEFAULT 'meeting'"))
# 4) Job.pdf_path nachrüsten (PDF-Export zusätzlich zu DOCX).
cols = _existing_columns(conn, "job")
if "pdf_path" not in cols:
log.info("Migration: füge job.pdf_path hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN pdf_path VARCHAR DEFAULT ''"))
# 5) Diagnose-Felder: step_started_at, last_heartbeat_at
cols = _existing_columns(conn, "job")
if "step_started_at" not in cols:
log.info("Migration: füge job.step_started_at hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN step_started_at DATETIME"))
if "last_heartbeat_at" not in cols:
log.info("Migration: füge job.last_heartbeat_at hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN last_heartbeat_at DATETIME"))
def assign_orphan_jobs_to(user_id: int) -> int:
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
with Session(engine) as session:
result = session.execute(
text("UPDATE job SET owner_id = :uid WHERE owner_id IS NULL"),
{"uid": user_id},
)
session.commit()
n = result.rowcount or 0
if n:
log.info("Migration: %d Bestand-Jobs an user_id=%d zugeordnet", n, user_id)
return n