import time import socket import platform import threading import logging from datetime import datetime, timezone from pathlib import Path from typing import Optional, Callable, Dict, Any, List import httpx from agent.config import AgentConfig, load_config, save_config, LocalFolderJob from agent.scanner import DirectoryScanner, is_file_stable from agent.chunker import compute_file_sha256 from agent.uploader import ChunkUploader from agent.state_db import state_db logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] [OnEver Agent] %(message)s" ) logger = logging.getLogger("OnEverAgent") class AgentDaemon: """Background service worker for Windows: handles heartbeats, job polling and scheduled backups.""" def __init__( self, config: Optional[AgentConfig] = None, on_started: Optional[Callable[[str, int], None]] = None, on_progress: Optional[Callable[[str, int, int, float], None]] = None, on_completed: Optional[Callable[[str, str, int], None]] = None, on_error: Optional[Callable[[str, str], None]] = None, on_status: Optional[Callable[[str, str], None]] = None ): self.config = config or load_config() self.running = False self.uploader = ChunkUploader(self.config) self._heartbeat_thread: Optional[threading.Thread] = None self._worker_thread: Optional[threading.Thread] = None # Event callbacks self.on_started = on_started self.on_progress = on_progress self.on_completed = on_completed self.on_error = on_error self.on_status = on_status def start(self): self.config = load_config() if not self.config.device_id or not self.config.device_token: logger.warning("Agent is not registered yet. Waiting for registration.") if self.on_status: self.on_status("UNREGISTERED", "El agente no está registrado en el servidor.") return self.running = True logger.info(f"Starting OnEver Drive Windows Agent ({self.config.client_code} - {self.config.client_name})") logger.info(f"Target Server: {self.config.server_url}") if self.on_status: self.on_status("ONLINE", f"Conectado a {self.config.server_url} ({self.config.client_code})") self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True) self._worker_thread = threading.Thread(target=self._backup_worker_loop, daemon=True) self._heartbeat_thread.start() self._worker_thread.start() def stop(self): logger.info("Stopping agent daemon...") self.running = False if self.on_status: self.on_status("PAUSED", "Servicio en pausa.") def _get_headers(self): return { "X-Device-Id": self.config.device_id, "X-Device-Token": self.config.device_token } def _heartbeat_loop(self): while self.running: try: base_url = self.config.server_url.rstrip("/") with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client: resp = client.get("/api/jobs/agent/assigned") if resp.status_code == 200: logger.debug("Heartbeat acknowledged by server.") except Exception as ex: logger.warning(f"Heartbeat failed: {str(ex)}") time.sleep(self.config.heartbeat_interval_seconds) def _backup_worker_loop(self): while self.running: try: self._run_backup_cycle() except Exception as ex: logger.error(f"Error during backup cycle: {str(ex)}") time.sleep(30) def _run_backup_cycle(self): self.config = load_config() self.uploader.config = self.config # 1. Fetch server-assigned jobs server_jobs = [] try: base_url = self.config.server_url.rstrip("/") with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client: resp = client.get("/api/jobs/agent/assigned") if resp.status_code == 200: server_jobs = resp.json() except Exception: pass # 2. Combine server jobs + user local folders all_jobs = [] for sj in server_jobs: all_jobs.append({ "job_id": sj.get("id"), "name": sj.get("name"), "source_path": sj.get("source_path"), "file_patterns": sj.get("file_patterns", "*.*"), "min_stable_seconds": sj.get("min_stable_time_seconds", 60) }) for lj in self.config.local_folders: if lj.is_active: all_jobs.append({ "job_id": None, "local_job_id": lj.id, "name": lj.name, "source_path": lj.source_path, "file_patterns": lj.file_patterns, "min_stable_seconds": lj.min_stable_seconds }) # 3. Process jobs for job in all_jobs: source_path = job["source_path"] file_patterns = job["file_patterns"] min_stable = job["min_stable_seconds"] job_name = job["name"] scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable) files = scanner.scan() for filepath in files: if not is_file_stable(filepath, min_stable_seconds=min_stable): logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.") continue current_sha = compute_file_sha256(filepath) if state_db.is_file_already_backed_up(str(filepath), current_sha): continue file_size_bytes = filepath.stat().st_size logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)") # Single notification when the process starts if self.on_started: self.on_started(filepath.name, file_size_bytes) def on_chunk_progress(done, total, pct): if self.on_progress: self.on_progress(filepath.name, done, total, pct) try: res = self.uploader.upload_file( filepath, job_id=job.get("job_id"), progress_callback=on_chunk_progress ) logger.info(f"Successfully backed up {filepath.name}!") # Update local folder last backup status if "local_job_id" in job: for folder in self.config.local_folders: if folder.id == job["local_job_id"]: folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") folder.last_status = "Backup Exitoso" save_config(self.config) # Single notification when the process finishes if self.on_completed: self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes) except Exception as ex: logger.error(f"Failed to backup {filepath.name}: {str(ex)}") if self.on_error: self.on_error(filepath.name, str(ex))