Files
cowork-local/domain/jira_knowledge/sync_state.py
T

112 lines
3.9 KiB
Python

"""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",
]