from __future__ import annotations import json import os import tempfile from pathlib import Path from typing import Any ALLOWED_REPLAY_ERROR_CATEGORIES = { "http_429", "http_5xx", "timeout", "network", "http_4xx", "missing_target", "command_failed", "other", } def sanitize_error_category_counts(value: Any) -> dict[str, int]: if not isinstance(value, dict): return {} counts: dict[str, int] = {} for category in sorted(ALLOWED_REPLAY_ERROR_CATEGORIES): try: count = int(value.get(category, 0) or 0) except (TypeError, ValueError): continue if count > 0: counts[category] = count return counts def atomic_write_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) file_descriptor, temp_name = tempfile.mkstemp( dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", text=True, ) temp_path = Path(temp_name) try: with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temp_path, path) directory_fd = os.open(path.parent, os.O_RDONLY) try: os.fsync(directory_fd) finally: os.close(directory_fd) finally: if temp_path.exists(): temp_path.unlink() def load_replay_queue(path: Path) -> dict[str, Any]: if not path.exists(): return {"version": 1, "entries": []} payload = json.loads(path.read_text(encoding="utf-8")) if not isinstance(payload, dict): raise ValueError(f"{path} must be a JSON object") if not isinstance(payload.get("entries"), list): payload["entries"] = [] return payload def persist_replay_intent( path: Path, *, entry_id: str, window_start: str, window_end: str, description_field: str, lead_ids: list[str], updated_at: str, ) -> dict[str, Any]: payload = load_replay_queue(path) entries = payload.setdefault("entries", []) normalized_new_ids = { str(item).strip() for item in lead_ids if str(item).strip() } matching_entries = [ entry for entry in entries if isinstance(entry, dict) and str(entry.get("id", "")) == entry_id ] if len(matching_entries) > 1: raise ValueError("replay queue contains duplicate window entries") for entry in matching_entries: if str(entry.get("window_start", "")) not in {"", window_start}: raise ValueError("replay queue entry window_start is immutable") if str(entry.get("window_end", "")) not in {"", window_end}: raise ValueError("replay queue entry window_end is immutable") existing_description_field = str(entry.get("description_field", "")).strip() if existing_description_field and existing_description_field != description_field: raise ValueError("replay queue entry description_field is immutable") existing_pending_ids = { str(item).strip() for item in entry.get("lead_ids", []) if str(item).strip() } completed_lead_ids = { str(item).strip() for item in entry.get("completed_lead_ids", []) if str(item).strip() } merged_pending_ids = sorted( (existing_pending_ids | normalized_new_ids) - completed_lead_ids ) all_candidate_ids = sorted( { str(item).strip() for item in entry.get("all_candidate_ids", []) if str(item).strip() } | existing_pending_ids | completed_lead_ids | set(merged_pending_ids) ) entry.update( { "status": ( "pending" if merged_pending_ids else str(entry.get("status", "success")) ), "lead_ids": merged_pending_ids, "candidate_count": len(merged_pending_ids), "all_candidate_ids": all_candidate_ids, "completed_lead_ids": sorted(completed_lead_ids), "original_candidate_count": len(all_candidate_ids), "consecutive_no_progress_count": int( entry.get("consecutive_no_progress_count", 0) or 0 ), "description_field": description_field, "updated_at": updated_at, "last_error": ( "" if merged_pending_ids else str(entry.get("last_error", "")) ), } ) atomic_write_json(path, payload) return entry normalized_ids = sorted(normalized_new_ids) entry = { "id": entry_id, "window_start": window_start, "window_end": window_end, "description_field": description_field, "lead_ids": normalized_ids, "candidate_count": len(normalized_ids), "all_candidate_ids": normalized_ids, "completed_lead_ids": [], "original_candidate_count": len(normalized_ids), "status": "pending" if normalized_ids else "success", "attempt_count": 0, "consecutive_no_progress_count": 0, "created_at": updated_at, "updated_at": updated_at, "last_error": "", } entries.append(entry) atomic_write_json(path, payload) return entry