from __future__ import annotations import json import os from datetime import datetime, timezone from pathlib import Path from threading import RLock from app.actuators.models import ( ActuatorRecord, JobQueueItem, JobQueueState, JobStatus, LifecycleStatus, ModelLifecycleState, ReconciliationState, model_id_for_actuator, ) class ActuatorStore: def __init__(self, root: str | Path) -> None: self._root = Path(root).resolve() self._actuators_root = self._root / "actuators" self._actuators_root.mkdir(parents=True, exist_ok=True) self._lock = RLock() self._reconciliation_state_path = self._root / "reconciliation_state.json" self._job_queue_path = self._root / "job_queue.json" def list(self) -> list[ActuatorRecord]: with self._lock: return [self._load(path) for path in sorted(self._actuators_root.glob("*.json"))] def get(self, actuator_entity_id: str) -> ActuatorRecord: with self._lock: target = self._target(actuator_entity_id) if not target.exists(): raise KeyError("Aktuator-Konfiguration nicht gefunden.") return self._load(target) def upsert(self, record: ActuatorRecord) -> ActuatorRecord: with self._lock: self._persist(record) return record def configure(self, actuator_entity_id: str, *, enabled: bool = True) -> ActuatorRecord: with self._lock: target = self._target(actuator_entity_id) if target.exists(): record = self._load(target) updated = record.model_copy( update={ "enabled": enabled, "updated_at": datetime.now(timezone.utc), } ) self._persist(updated) return updated record = ActuatorRecord( actuator_entity_id=actuator_entity_id, enabled=enabled, lifecycle=ModelLifecycleState( model_id=model_id_for_actuator(actuator_entity_id), status=LifecycleStatus.PENDING_ASSIGNMENT, ), ) self._persist(record) return record def delete(self, actuator_entity_id: str) -> None: with self._lock: target = self._target(actuator_entity_id) if target.exists(): target.unlink() def load_reconciliation_state(self) -> ReconciliationState: with self._lock: if not self._reconciliation_state_path.exists(): return ReconciliationState() try: return ReconciliationState.model_validate_json( self._reconciliation_state_path.read_text(encoding="utf-8") ) except ValueError as exc: raise ValueError("Ungültiger Reconciliation-Status.") from exc def save_reconciliation_state(self, state: ReconciliationState) -> ReconciliationState: with self._lock: self._persist_reconciliation_state(state) return state def load_job_queue(self) -> JobQueueState: with self._lock: if not self._job_queue_path.exists(): return JobQueueState() try: return JobQueueState.model_validate_json( self._job_queue_path.read_text(encoding="utf-8") ) except ValueError as exc: raise ValueError("Ungültiger Job-Queue-Status.") from exc def save_job_queue(self, queue: JobQueueState) -> JobQueueState: with self._lock: self._persist_job_queue(queue) return queue def start_job( self, *, kind: str, trigger: str, target: str | None = None, summary: str = "", ) -> JobQueueItem: now = datetime.now(timezone.utc) job = JobQueueItem( job_id=f"{now.strftime('%Y%m%d%H%M%S%f')}-{kind}-{target or 'all'}", kind=kind, target=target, trigger=trigger, status=JobStatus.RUNNING, started_at=now, summary=summary, ) with self._lock: queue = self.load_job_queue() queue.jobs = [*queue.jobs, job][-50:] self._persist_job_queue(queue) return job def finish_job( self, job_id: str, *, status: JobStatus, summary: str = "", error: str | None = None, ) -> JobQueueItem | None: now = datetime.now(timezone.utc) with self._lock: queue = self.load_job_queue() updated_job: JobQueueItem | None = None jobs: list[JobQueueItem] = [] for job in queue.jobs: if job.job_id != job_id: jobs.append(job) continue duration_ms = None if job.started_at is not None: duration_ms = max(0, int((now - job.started_at).total_seconds() * 1000)) updated_job = job.model_copy( update={ "status": status, "completed_at": now, "duration_ms": duration_ms, "summary": summary or job.summary, "error": error, } ) jobs.append(updated_job) queue.jobs = jobs[-50:] self._persist_job_queue(queue) return updated_job def _target(self, actuator_entity_id: str) -> Path: if "." not in actuator_entity_id: raise ValueError("Ungültige actuator_entity_id.") safe_name = actuator_entity_id.replace(".", "__") return self._actuators_root / f"{safe_name}.json" def _persist(self, record: ActuatorRecord) -> None: target = self._target(record.actuator_entity_id) temporary = target.with_suffix(".json.tmp") temporary.write_text( json.dumps(record.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8", ) os.replace(temporary, target) def _persist_reconciliation_state(self, state: ReconciliationState) -> None: temporary = self._reconciliation_state_path.with_suffix(".json.tmp") temporary.write_text( json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8", ) os.replace(temporary, self._reconciliation_state_path) def _persist_job_queue(self, state: JobQueueState) -> None: temporary = self._job_queue_path.with_suffix(".json.tmp") temporary.write_text( json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8", ) os.replace(temporary, self._job_queue_path) @staticmethod def _load(path: Path) -> ActuatorRecord: try: return ActuatorRecord.model_validate_json(path.read_text(encoding="utf-8")) except ValueError as exc: raise ValueError(f"Ungültige Aktuator-Konfiguration: {path.name}") from exc