"""Drive the Run tab's 7-box checkpoint tracker. kind: 'active' (box cp is now running) | 'done' (box cp finished) | 'stuck' (box cp overran its max time).""" self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)}) def _on_automation_line(self, line, level): """Called (from the worker thread) for every printed line.""" # Watchdog checkpoint state marker -> 7-box tracker in the Run tab # (raw line still stored in the DB log below). if "[CPSTATE]" in line: try: kind, cp = line.split("[CPSTATE]", 1)[1].split() self._cpstate(kind, cp) # Feed the parent-side stall watchdog. The automation runs in a # CHILD process, so the in-process performer.WD_STATE the old # watchdog read is ALWAYS empty here (it never fired — a stuck # checkpoint never restarted). Track the active checkpoint + its # start time from the STREAMED [CPSTATE] line instead. if kind == "active": self._wd_cp = int(cp) self._wd_since = time.time() except Exception: pass low = line.lower() ltype = "info" if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low: ltype = "error" elif "warning" in low or "[warn" in low: ltype = "warn" elif "processed successfully" in low or "[all done]" in low or "success" in low: ltype = "success" # The FULL raw technical line always goes to the per-batch DB log AND to # the DB-independent file log (the file works even when the DB is down). if self._batch_log is not None: self._batch_log.log(ltype, line) if self._run_log_file is not None: try: self._run_log_file.write(line + "\n") except Exception: pass # The on-screen log gets the plain-English translation only; raw lines # stay off the screen — except untranslated ERRORS, which are shown # as-is (a failure must never be invisible to the user). shown = friendly_log_line(line) if shown is not None: if shown[0] != self._last_friendly: # collapse repeated polling lines self._log(shown[0], shown[1]) self._last_friendly = shown[0] elif ltype == "error" and not _is_error_noise(low): # An untranslated real error still shows (a failure must never be # invisible) — but raw traceback/COM noise is filtered out. self._log(line, "error") self._last_friendly = None # advance the progress bar / step list on known markers for marker, step_idx, pct in STEP_MARKERS: if marker in low: if step_idx > self._cur_step: self._cur_step = step_idx if pct > self._max_pct: self._max_pct = pct self._step(STEPS[self._cur_step], self._max_pct) break def _resolve_input_path(self, p): """Drag-drop in QWebEngine often hides the real disk path, so we may only get a bare filename. Resolve it against the known input folders so the automation always receives a real, absolute path.""" if p and os.path.isabs(p) and os.path.exists(p): return p base = os.path.basename(p) if p else "" for folder in (INPUT_DIR, _ROOT): cand = os.path.join(folder, base) if base else "" if cand and os.path.exists(cand): return cand return p # unresolved — caller will report a clear error def _run_multi_subprocess(self, excel_path, emp): """Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread, no Qt) and stream its stdout back through this run's log pipeline. Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump for cross-process UIA; forcing MTA dead-locks against the Qt main thread's UIA object). The reference tool works because it is a separate process with no Qt — this reproduces that. Every child stdout line is re-printed here so it flows through _StreamToBridge → _on_automation_line exactly as an in-process print did (friendly log, [CPSTATE] tracker, per-batch DB log, file log). Returns the run_multi summary dict. """ import subprocess import time as _time proj_root = os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))) # The child writes ALL its output to THIS file and we tail it live. A # --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable # (v1.1.5: nothing streamed back), but a line-buffered file always # flushes per line and can be read while the process runs. try: os.makedirs(LOGS_DIR, exist_ok=True) except Exception: pass child_log = os.path.join( LOGS_DIR, f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}" f"_{self.batch_id or 0}.log") try: open(child_log, "w", encoding="utf-8").close() # start empty except Exception: pass if getattr(sys, "frozen", False): cmd = [sys.executable, "--run-automation"] else: cmd = [sys.executable, os.path.join(proj_root, "run_app.py"), "--run-automation"] cmd += ["--excel", str(excel_path), "--batch", str(self.batch_id or ""), "--emp", str(emp or ""), "--log", child_log, # Parent-death watchdog: the child exits itself the instant this # GUI process dies (crash, taskkill, or a closeEvent we couldn't # run) so automation can never keep driving Tebra orphaned. "--parent-pid", str(os.getpid())] env = dict(os.environ) env["PYTHONUNBUFFERED"] = "1" creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \ if sys.platform.startswith("win") else 0 print(f"[Automation] Launching automation in its own process " f"(reliable UIA) — batch {self.batch_id} …") try: proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=proj_root, env=env, creationflags=creationflags) except Exception as e: print(f"[Automation] Could not launch automation process: {e}") return {"total": 0, "ok": [], "failed": [("Automation", f"launch failed: {e}")], "stopped": False} self._auto_proc = proc summary = {} got_summary = False def _handle(line): nonlocal summary, got_summary if line.startswith("__WF_SUMMARY__"): try: summary = json.loads(line[len("__WF_SUMMARY__"):]) or {} got_summary = True except Exception: pass elif line.strip(): print(line) # → _StreamToBridge tee → _on_automation_line # Tail the child log until the process exits, then drain the rest. try: with open(child_log, "r", encoding="utf-8", errors="replace") as f: while True: pos = f.tell() line = f.readline() if line and line.endswith("\n"): _handle(line.rstrip("\r\n")) elif line: # partial line (no newline yet) if proc.poll() is not None: _handle(line.rstrip("\r\n"))