"""Sync state persistence for Jira Project Knowledge. A ``SyncManifest`` records the operational state of one project's Jira sync: when it last succeeded, how many issues were processed or failed, and the incremental checkpoint (Jira ``updated > timestamp``) for the next run. Persistence uses atomic JSON writes so a crash mid-sync cannot corrupt the manifest and cause duplicate or lost work on recovery. """ from __future__ import annotations import json from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Optional @dataclass class SyncManifest: """Operational state of one project's Jira knowledge sync. All timestamps are ISO-8601 UTC strings. ``sync_cursor`` is the Jira ``updated`` timestamp watermark; the next incremental sync fetches issues with ``updated >= sync_cursor``. """ project_id: str jira_project_key: str = "" last_successful_sync: str = "" last_attempted_sync: str = "" sync_cursor: str = "" processed_count: int = 0 failed_count: int = 0 error_category: str = "" total_issues_indexed: int = 0 sync_duration_seconds: float = 0.0 extra: Dict[str, Any] = field(default_factory=dict) def mark_attempt(self) -> None: """Record that a sync attempt has started.""" self.last_attempted_sync = datetime.now(timezone.utc).isoformat() def mark_success( self, *, processed: int, failed: int, cursor: str, duration: float, total_indexed: int, ) -> None: """Record a completed sync with its outcomes.""" now = datetime.now(timezone.utc).isoformat() self.last_successful_sync = now self.last_attempted_sync = now self.processed_count = processed self.failed_count = failed self.sync_cursor = cursor self.sync_duration_seconds = round(duration, 2) self.total_issues_indexed = total_indexed self.error_category = "" def mark_failure(self, category: str, failed: int = 0) -> None: """Record a failed sync attempt without losing the previous cursor.""" self.last_attempted_sync = datetime.now(timezone.utc).isoformat() self.error_category = category if failed: self.failed_count = failed def _manifest_path(index_root: Path, project_id: str) -> Path: """Deterministic manifest path for one project.""" safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in project_id) return index_root / safe / "manifest.json" def load_manifest(index_root: Path, project_id: str) -> SyncManifest: """Load the manifest for ``project_id``, returning a fresh one if absent. Never raises on missing or corrupt files — a missing manifest simply means "first sync", and a corrupt one is treated the same way (the operator can inspect the file manually if needed). """ path = _manifest_path(index_root, project_id) if not path.exists(): return SyncManifest(project_id=project_id) try: data = json.loads(path.read_text(encoding="utf-8")) known = {f.name for f in SyncManifest.__dataclass_fields__.values()} return SyncManifest(**{k: v for k, v in data.items() if k in known}) except (OSError, json.JSONDecodeError, TypeError): return SyncManifest(project_id=project_id) def save_manifest(index_root: Path, manifest: SyncManifest) -> None: """Atomically persist ``manifest`` to disk. Creates the project directory if it does not exist. Uses the shared atomic-write helper so a crash between truncate and write cannot leave a half-written manifest. """ path = _manifest_path(index_root, manifest.project_id) path.parent.mkdir(parents=True, exist_ok=True) from ...infrastructure.persistence.json.atomic_write import write_json write_json(path, asdict(manifest)) __all__ = [ "SyncManifest", "load_manifest", "save_manifest", ]