feat: echter Download-Fortschritt via Datei-Polling

`hf` gibt im Nicht-TTY-Modus keinen Fortschritt aus (am Bosgame verifiziert:
0 CR-Frames). Stattdessen pollt jobengine.attach_download_progress die
wachsende <local-dir>/.cache/huggingface/download/*.incomplete-Datei gegen die
Gesamtgroesse aus der HF-Tree-API (cookbook.hf_file_size) -> exaktes %.

- attach_download_progress an /api/download, install-recipe, install-model
- Frontend (Aktivitaet + Server-Karte): nutzt job.progress bevorzugt,
  Log-%-Parsing bleibt Fallback fuer Tools, die selbst Prozente ausgeben

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 18:26:27 +02:00
parent 2cde6ba5a5
commit 3cf36d436b
5 changed files with 68 additions and 7 deletions
+33
View File
@@ -6,6 +6,7 @@ zeilenweisem Log-Capture. Keine Persistenz, kein Broker. Genutzt von allen
Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...).
"""
import glob
import os
import shlex
import subprocess
@@ -102,6 +103,38 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data:
job["finished_at"] = time.time()
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Echten Download-Fortschritt (in %) auf den Job legen. `hf` gibt im Nicht-TTY-
Modus keinen Fortschritt aus, schreibt aber in <local_dir>/.cache/huggingface/
download/*.incomplete (waechst). Wir vergleichen dessen Groesse mit total_bytes
(aus der HF-Tree-API). Ein Daemon-Thread aktualisiert job["progress"]."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id)
if job is not None:
job["progress"] = 0
def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "*.incomplete")
while True:
j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"):
break
try:
inc = glob.glob(pat)
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
if cur:
j["progress"] = min(99, int(cur * 100 / total_bytes))
except Exception: # noqa: BLE001
pass
time.sleep(1.0)
j = JOBS.get(job_id)
if j and j["state"] == "done":
j["progress"] = 100
threading.Thread(target=_watch, daemon=True).start()
def cancel_job(job_id: str) -> bool:
"""Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job
nicht (mehr) laeuft oder unbekannt ist."""