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") def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool: if not schedule_str: return True if not last_run_str: return True try: # Try parsing ISO (from server) or standard YYYY-MM-DD HH:MM:SS (local) if "T" in last_run_str: last_run = datetime.fromisoformat(last_run_str.replace("Z", "+00:00")) else: last_run = datetime.strptime(last_run_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) except Exception: return True now = datetime.now(timezone.utc) delta = now - last_run sched = schedule_str.lower().strip() if sched == "hourly": return delta.total_seconds() >= 3600 elif sched == "daily": return delta.total_seconds() >= 86400 elif sched == "weekly": return delta.total_seconds() >= 86400 * 7 elif sched == "monthly": return delta.total_seconds() >= 86400 * 30 else: return True 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.last_server_jobs = [] 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, force: bool = False): self.config = load_config() self.uploader.config = self.config # 1. Fetch server-assigned jobs and run bidirectional sync server_jobs = [] sync_success = False 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: server_jobs = resp.json() self.last_server_jobs = server_jobs sync_success = True except Exception as e: logger.warning(f"Could not fetch server jobs: {e}") config_changed = False if sync_success: # A. Sync Server -> Local server_job_ids = {sj["id"] for sj in server_jobs} # Remove local jobs that have a job_id but are not on the server anymore (deleted on server) local_jobs_to_keep = [] for lj in self.config.local_folders: if lj.job_id is None: # New local job, keep it so we register it next local_jobs_to_keep.append(lj) elif lj.job_id in server_job_ids: # Keep it and update local properties from server sj = next(x for x in server_jobs if x["id"] == lj.job_id) lj.name = sj.get("name", lj.name) lj.source_path = sj.get("source_path", lj.source_path) lj.file_patterns = sj.get("file_patterns", lj.file_patterns) lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron) lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds) # Also sync last_run_at from server if available and newer if sj.get("last_run_at"): lj.last_backup_at = sj["last_run_at"].replace("T", " ")[:19] lj.last_status = sj.get("status", lj.last_status) local_jobs_to_keep.append(lj) else: # Deleted on server, don't keep it config_changed = True self.config.local_folders = local_jobs_to_keep # Add server jobs that are missing locally local_job_ids = {lj.job_id for lj in self.config.local_folders if lj.job_id is not None} for sj in server_jobs: if sj["id"] not in local_job_ids: new_job = LocalFolderJob( job_id=sj["id"], name=sj["name"], source_path=sj["source_path"], file_patterns=sj["file_patterns"], schedule_cron=sj["schedule_cron"], min_stable_seconds=sj["min_stable_time_seconds"], last_status=sj.get("status", "En espera") ) if sj.get("last_run_at"): new_job.last_backup_at = sj["last_run_at"].replace("T", " ")[:19] self.config.local_folders.append(new_job) config_changed = True # B. Sync Local -> Server (Register new local folders on the server) for lj in self.config.local_folders: if lj.job_id is None: try: base_url = self.config.server_url.rstrip("/") payload = { "name": lj.name, "source_path": lj.source_path, "file_patterns": lj.file_patterns, "schedule_cron": lj.schedule_cron, "min_stable_time_seconds": lj.min_stable_seconds } resp = httpx.post(f"{base_url}/api/jobs/agent/register", headers=self._get_headers(), json=payload, timeout=10.0) if resp.status_code == 200: data = resp.json() lj.job_id = data["id"] config_changed = True logger.info(f"Registered local job '{lj.name}' on server with ID {lj.job_id}") except Exception as e: logger.warning(f"Could not register local job '{lj.name}' on server: {e}") if config_changed: save_config(self.config) # 2. Process active jobs for job in self.config.local_folders: if not job.is_active: continue # Check if job is due or forced if not force and not is_job_due(job.schedule_cron, job.last_backup_at): continue source_path = job.source_path file_patterns = job.file_patterns min_stable = job.min_stable_seconds job_name = job.name # Skip if path does not exist if not Path(source_path).exists(): logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.") continue scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable) files = scanner.scan() # Track files successfully backed up in this run 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)") 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.job_id, progress_callback=on_chunk_progress ) logger.info(f"Successfully backed up {filepath.name}!") 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)}") job.last_status = "Error" save_config(self.config) if self.on_error: self.on_error(filepath.name, str(ex)) # Update job state in config after checking directory job.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error" save_config(self.config)