"""Harmony resolution persistence — save, load, list, increment, best, gc_stale. Single responsibility: CRUD for Resolution objects in the harmony store plus the GC helper that prunes stale unresolved patterns. Imports _write_atomic from .patterns (the shared atomic-write helper). """ from __future__ import annotations import datetime import json import logging import pathlib from collections.abc import Mapping from dataclasses import replace as dc_replace from muse.core.types import JsonValue, load_json_file, long_id, short_id from .fingerprint import _now_utc, _parse_dt from .paths import ( _BARE_HEX64_RE, _PATTERN_FILE, _pattern_entry_dir, _resolutions_dir, _resolution_path, _validate_id, ) from .patterns import _write_atomic, forget_pattern, list_patterns from .types import ( AgentProvenance, Resolution, ResolutionStrategy, _ResolutionDict, ) logger = logging.getLogger(__name__) #: Maximum bytes read from a resolution JSON file. _MAX_RESOLUTION_BYTES: int = 16_384 # 16 KiB # --------------------------------------------------------------------------- # Serialisation helpers # --------------------------------------------------------------------------- def _resolution_to_dict(resolution: Resolution) -> _ResolutionDict: return _ResolutionDict( resolution_id=resolution.resolution_id, pattern_id=resolution.pattern_id, strategy=resolution.strategy, policy_id=resolution.policy_id, outcome_blob=resolution.outcome_blob, resolved_by=resolution.resolved_by.to_dict(), human_verified=resolution.human_verified, confidence=resolution.confidence, rationale=resolution.rationale, resolved_at=resolution.resolved_at.isoformat(), applied_count=resolution.applied_count, ) def _dict_to_resolution(data: Mapping[str, JsonValue]) -> Resolution | None: """Deserialise a JSON dict to :class:`Resolution`, returning ``None`` on error.""" try: return Resolution( resolution_id=str(data["resolution_id"]), pattern_id=str(data.get("pattern_id", "")), strategy=str(data.get("strategy", ResolutionStrategy.MANUAL)), policy_id=data.get("policy_id") or None, outcome_blob=str(data.get("outcome_blob", "")), resolved_by=AgentProvenance.from_dict(data.get("resolved_by") or {}), human_verified=bool(data.get("human_verified", False)), confidence=float(data.get("confidence", 0.0)), rationale=str(data.get("rationale", "")), resolved_at=_parse_dt(data.get("resolved_at")), applied_count=int(data.get("applied_count", 0)), ) except (KeyError, TypeError, ValueError) as exc: logger.warning("⚠️ harmony: failed to deserialise resolution: %s", exc) return None # --------------------------------------------------------------------------- # Resolution CRUD # --------------------------------------------------------------------------- def save_resolution(root: pathlib.Path, resolution: Resolution) -> None: """Persist a :class:`Resolution` to the harmony store. Stored at ``.muse/harmony/patterns//resolutions/.json``. **Idempotent** — saving the same resolution_id twice is a no-op. Args: root: Repository root. resolution: Resolution to save. Raises: ValueError: If either ID fails hex validation. FileNotFoundError: If the parent pattern does not exist. Call :func:`record_pattern` first. """ _validate_id(resolution.pattern_id, "pattern_id") _validate_id(resolution.resolution_id, "resolution_id") pattern_p = _pattern_entry_dir(root, resolution.pattern_id) / _PATTERN_FILE if not pattern_p.exists(): raise FileNotFoundError( f"No harmony pattern found for pattern_id {resolution.pattern_id}. " "Call record_pattern() before save_resolution()." ) dest = _resolution_path(root, resolution.pattern_id, resolution.resolution_id) if dest.exists(): logger.debug( "harmony: resolution %s already saved for pattern %s", short_id(resolution.resolution_id), short_id(resolution.pattern_id), ) return _write_atomic(dest, json.dumps(_resolution_to_dict(resolution), indent=2)) logger.debug( "harmony: saved resolution %s for pattern %s " "(strategy=%s, confidence=%.2f, verified=%s)", short_id(resolution.resolution_id), short_id(resolution.pattern_id), resolution.strategy, resolution.confidence, resolution.human_verified, ) def load_resolution( root: pathlib.Path, pattern_id: str, resolution_id: str, ) -> Resolution | None: """Load a :class:`Resolution` from the harmony store. Returns ``None`` when either ID fails validation, the file does not exist, it exceeds :data:`_MAX_RESOLUTION_BYTES`, or JSON parsing fails. Args: root: Repository root. pattern_id: ``sha256:`` content-addressed pattern ID. resolution_id: ``sha256:`` content-addressed resolution ID. """ try: _validate_id(pattern_id, "pattern_id") _validate_id(resolution_id, "resolution_id") except ValueError: return None dest = _resolution_path(root, pattern_id, resolution_id) if not dest.exists(): return None try: size = dest.stat().st_size except OSError as exc: logger.warning( "⚠️ harmony: failed to read resolution %s: %s", resolution_id, exc ) return None if size > _MAX_RESOLUTION_BYTES: logger.warning( "⚠️ harmony: resolution %s is %d bytes — exceeds %d cap; skipping", resolution_id, size, _MAX_RESOLUTION_BYTES, ) return None data = load_json_file(dest) if data is None: logger.warning( "⚠️ harmony: failed to read resolution %s: unreadable or invalid JSON", resolution_id, ) return None return _dict_to_resolution(data) def list_resolutions(root: pathlib.Path, pattern_id: str) -> list[Resolution]: """Return all :class:`Resolution` entries for *pattern_id*. Skips symlinks and files that fail to parse. Sorted by quality descending: ``(human_verified, confidence, applied_count)`` — highest quality first. Args: root: Repository root. pattern_id: ``sha256:`` content-addressed pattern ID. """ try: res_dir = _resolutions_dir(root, pattern_id) except ValueError: return [] if not res_dir.exists(): return [] resolutions: list[Resolution] = [] for algo_dir in res_dir.iterdir(): if algo_dir.is_symlink() or not algo_dir.is_dir(): continue for f in algo_dir.iterdir(): if f.is_symlink() or not f.is_file(): continue if not f.name.endswith(".json"): continue bare_rid = f.name[:-5] if not _BARE_HEX64_RE.match(bare_rid): continue r = load_resolution(root, pattern_id, long_id(bare_rid, algo_dir.name)) if r is not None: resolutions.append(r) resolutions.sort( key=lambda r: (r.human_verified, r.confidence, r.applied_count), reverse=True, ) return resolutions def increment_applied_count( root: pathlib.Path, pattern_id: str, resolution_id: str, ) -> bool: """Atomically increment the ``applied_count`` of a :class:`Resolution`. Loads the current resolution, creates an updated copy via :func:`dataclasses.replace`, and writes it back atomically. Returns ``True`` on success, ``False`` if the resolution does not exist. Args: root: Repository root. pattern_id: ``sha256:`` content-addressed pattern ID. resolution_id: ``sha256:`` content-addressed resolution ID. """ resolution = load_resolution(root, pattern_id, resolution_id) if resolution is None: return False updated = dc_replace(resolution, applied_count=resolution.applied_count + 1) dest = _resolution_path(root, pattern_id, resolution_id) _write_atomic(dest, json.dumps(_resolution_to_dict(updated), indent=2)) logger.debug( "harmony: applied_count → %d for resolution %s", updated.applied_count, short_id(resolution_id), ) return True def best_resolution(root: pathlib.Path, pattern_id: str) -> Resolution | None: """Return the highest-quality :class:`Resolution` for *pattern_id*. Quality ranking: ``human_verified`` > ``confidence`` > ``applied_count``. Returns ``None`` if no resolutions exist for the pattern. Args: root: Repository root. pattern_id: ``sha256:`` content-addressed pattern ID. """ resolutions = list_resolutions(root, pattern_id) return resolutions[0] if resolutions else None # --------------------------------------------------------------------------- # GC # --------------------------------------------------------------------------- def gc_stale(root: pathlib.Path, age_days: int = 90) -> int: """Remove patterns that have no resolution and are older than *age_days*. Patterns with at least one saved resolution are retained regardless of age. Args: root: Repository root. age_days: Age threshold in days (default 90). Returns: Number of patterns removed. """ cutoff = _now_utc() - datetime.timedelta(days=age_days) removed = 0 for p in list_patterns(root): res_dir = _resolutions_dir(root, p.pattern_id) has_any_resolution = ( res_dir.exists() and any( True for algo_dir in res_dir.iterdir() if not algo_dir.is_symlink() and algo_dir.is_dir() for f in algo_dir.iterdir() if not f.is_symlink() and f.is_file() and f.name.endswith(".json") ) ) if has_any_resolution: continue if p.recorded_at < cutoff: if forget_pattern(root, p.pattern_id): removed += 1 logger.debug( "harmony gc: removed %d stale unresolved patterns (age_days=%d)", removed, age_days, ) return removed