"""monitor_executions.py — sorveglianza dell'esecuzione job su Ufficio, letta direttamente dai file di log — non più un database (deciso con Mauro il 07/08/2026: "più semplice è meglio è" + niente più la classe di bug già vista due volte oggi con SQLite+Dropbox, connessione agganciata al vecchio inode dopo che Dropbox sostituisce il file — una directory letta al volo con glob() non ha quel problema). Obiettivo esplicito di Mauro: "ottimo controllo per quanto possibile dalla Nave di quello che succede DURANTE l'esecuzione... capire bene e subito se qualcosa va storto" — non solo un verdetto a cose fatte. Per questo: - notifica quando un nuovo .log appare (job partito) - notifica ogni riga con ERROR/Traceback/Exception non appena scritta (warning precoce, anche prima che il job finisca o vada in timeout) - notifica il verdetto finale quando appare la riga "=== ESITO: ..." che IndependentJobRunner scrive sempre in coda al log (job_runner.py) Usa il modulo sqlite3 di Python — no, non più: solo file. Poll ogni POLL_SECONDS su logs/*.log, lettura incrementale (tiene un offset per file, legge solo il contenuto nuovo ad ogni giro). """ import re import time from pathlib import Path LOG_DIR = Path(__file__).resolve().parent.parent / "ufficio" / "logs" POLL_SECONDS = 5 ESITO_RE = re.compile(r"^=== ESITO: (\w+) exit_code=(-?\d+) duration=([\d.]+)s ===") WARNING_RE = re.compile(r"ERROR|Traceback|Exception", re.IGNORECASE) def main(): LOG_DIR.mkdir(parents=True, exist_ok=True) # I log gia' presenti all'avvio del Monitor non sono nuovi eventi — # segna il loro offset a fine file per non rinotificarli. offsets = {p.name: p.stat().st_size for p in LOG_DIR.glob("*.log")} seen = set(offsets) active = set() while True: current = {p.name for p in LOG_DIR.glob("*.log")} for name in sorted(current - seen): print(f"[avviato] {name}", flush=True) offsets[name] = 0 active.add(name) seen |= current for name in list(active): path = LOG_DIR / name if not path.exists(): active.discard(name) continue size = path.stat().st_size offset = offsets.get(name, 0) if size < offset: offset = 0 # file troncato/riscritto — raro, riparti da capo with open(path, "r", encoding="utf-8", errors="replace") as f: f.seek(offset) new_content = f.read() offsets[name] = f.tell() for line in new_content.splitlines(): m = ESITO_RE.match(line) if m: status, exit_code, duration = m.groups() print(f"[concluso] {name}: status={status} exit_code={exit_code} duration={duration}s", flush=True) active.discard(name) elif WARNING_RE.search(line): print(f"[warning] {name}: {line.strip()}", flush=True) time.sleep(POLL_SECONDS) if __name__ == "__main__": main()