import json import os import re from datetime import datetime, timezone from typing import Dict, Any, List, Optional _CURRENT_RUN_NUMBER: Optional[int] = None DEFAULT_MAX_RUNS = 10 def _get_default_logs_dir() -> str: """Return the absolute path to the backend/logs directory.""" utils_dir = os.path.dirname(os.path.abspath(__file__)) backend_dir = os.path.dirname(os.path.dirname(utils_dir)) return os.path.join(backend_dir, "logs") def _read_counter(counter_file: str) -> Optional[int]: """Read the last run number from the counter file if it exists.""" if not os.path.exists(counter_file): return None try: with open(counter_file, "r", encoding="utf-8") as f: content = f.read().strip() if content.isdigit(): return int(content) except Exception: pass return None def _find_max_existing_run(logs_dir: str) -> int: """Scan existing run_*.log files to find the highest run number.""" if not os.path.exists(logs_dir): return 0 pattern = re.compile(r"^run_(\d+)\.log$") max_num = 0 for name in os.listdir(logs_dir): match = pattern.match(name) if match: max_num = max(max_num, int(match.group(1))) return max_num def prune_old_runs(logs_dir: str, max_runs: int = DEFAULT_MAX_RUNS) -> List[str]: """ Ensure only the latest `max_runs` run files are kept. Deletes the oldest run_*.log files exceeding the limit. Returns the list of deleted filenames. """ if not os.path.exists(logs_dir) or max_runs <= 0: return [] pattern = re.compile(r"^run_(\d+)\.log$") run_files = [] for name in os.listdir(logs_dir): match = pattern.match(name) if match: run_files.append((int(match.group(1)), name)) # Sort ascending by run number run_files.sort(key=lambda x: x[0]) deleted = [] if len(run_files) > max_runs: excess_count = len(run_files) - max_runs to_delete = run_files[:excess_count] for _, name in to_delete: file_path = os.path.join(logs_dir, name) try: os.remove(file_path) deleted.append(name) except Exception: pass _prune_history_file(logs_dir, max_runs) return deleted def _prune_history_file(logs_dir: str, max_runs: int): """Keep only the last `max_runs` entries in run_history.json.""" history_file = os.path.join(logs_dir, "run_history.json") if not os.path.exists(history_file): return try: with open(history_file, "r", encoding="utf-8") as f: history = json.load(f) if isinstance(history, list) and len(history) > max_runs: history = history[-max_runs:] with open(history_file, "w", encoding="utf-8") as f: json.dump(history, f, indent=2) except Exception: pass def _record_run_history(logs_dir: str, run_number: int, log_file: str, max_runs: int): """Append a record for this run in run_history.json and prune older entries.""" history_file = os.path.join(logs_dir, "run_history.json") history = [] if os.path.exists(history_file): try: with open(history_file, "r", encoding="utf-8") as f: loaded = json.load(f) if isinstance(loaded, list): history = loaded except Exception: history = [] history.append({ "run_number": run_number, "timestamp": datetime.now(timezone.utc).isoformat(), "log_file": os.path.basename(log_file), }) if len(history) > max_runs: history = history[-max_runs:] try: with open(history_file, "w", encoding="utf-8") as f: json.dump(history, f, indent=2) except Exception: pass def initialize_run( logs_dir: Optional[str] = None, max_runs: Optional[int] = None, force_new: bool = False, ) -> int: """ Initialize the run for this backend execution: - Increments the persistent run counter by 1 (or starts at 1). - Prunes older runs so only the last `max_runs` (default 10) are kept. - Creates/touches the log file for this run. - Caches the current run number for the process. """ global _CURRENT_RUN_NUMBER if _CURRENT_RUN_NUMBER is not None and not force_new: return _CURRENT_RUN_NUMBER if logs_dir is None: logs_dir = _get_default_logs_dir() if max_runs is None: env_max = os.getenv("MAX_LOG_RUNS", "").strip() max_runs = int(env_max) if env_max.isdigit() else DEFAULT_MAX_RUNS os.makedirs(logs_dir, exist_ok=True) counter_file = os.path.join(logs_dir, "run_counter.txt") last_run = _read_counter(counter_file) if last_run is None: last_run = _find_max_existing_run(logs_dir) new_run = last_run + 1 # Persist the new run number with open(counter_file, "w", encoding="utf-8") as f: f.write(str(new_run)) _CURRENT_RUN_NUMBER = new_run # Create run log file run_log_file = os.path.join(logs_dir, f"run_{new_run}.log") if not os.path.exists(run_log_file): with open(run_log_file, "a", encoding="utf-8"): pass # Prune older runs prune_old_runs(logs_dir, max_runs=max_runs) # Record in history _record_run_history(logs_dir, new_run, run_log_file, max_runs) return new_run def get_current_run_number(logs_dir: Optional[str] = None) -> int: """Return the current cached run number, or initialize one if not yet done.""" global _CURRENT_RUN_NUMBER if _CURRENT_RUN_NUMBER is None: return initialize_run(logs_dir=logs_dir) return _CURRENT_RUN_NUMBER def get_current_run_log_file(logs_dir: Optional[str] = None) -> str: """Return the absolute path to the current run's log file.""" if logs_dir is None: logs_dir = _get_default_logs_dir() run_num = get_current_run_number(logs_dir=logs_dir) return os.path.join(logs_dir, f"run_{run_num}.log") def get_run_info(logs_dir: Optional[str] = None, max_runs: Optional[int] = None) -> Dict[str, Any]: """Return structured metadata about the current and recent runs.""" if logs_dir is None: logs_dir = _get_default_logs_dir() if max_runs is None: env_max = os.getenv("MAX_LOG_RUNS", "").strip() max_runs = int(env_max) if env_max.isdigit() else DEFAULT_MAX_RUNS current_run = get_current_run_number(logs_dir=logs_dir) # Inspect present run files pattern = re.compile(r"^run_(\d+)\.log$") present_runs = [] if os.path.exists(logs_dir): for name in os.listdir(logs_dir): match = pattern.match(name) if match: full_path = os.path.join(logs_dir, name) stat = os.stat(full_path) present_runs.append({ "run_number": int(match.group(1)), "filename": name, "size_bytes": stat.st_size, "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), }) present_runs.sort(key=lambda x: x["run_number"]) return { "run_number": current_run, "max_runs": max_runs, "current_log_file": os.path.basename(get_current_run_log_file(logs_dir=logs_dir)), "available_runs": present_runs, }