resolutions.py
python
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
7 days ago
| 1 | """Harmony resolution persistence — save, load, list, increment, best, gc_stale. |
| 2 | |
| 3 | Single responsibility: CRUD for Resolution objects in the harmony store plus |
| 4 | the GC helper that prunes stale unresolved patterns. |
| 5 | |
| 6 | Imports _write_atomic from .patterns (the shared atomic-write helper). |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import datetime |
| 12 | import json |
| 13 | import logging |
| 14 | import pathlib |
| 15 | from collections.abc import Mapping |
| 16 | from dataclasses import replace as dc_replace |
| 17 | |
| 18 | from muse.core.types import JsonValue, load_json_file, long_id, short_id |
| 19 | |
| 20 | from .fingerprint import _now_utc, _parse_dt |
| 21 | from .paths import ( |
| 22 | _BARE_HEX64_RE, |
| 23 | _PATTERN_FILE, |
| 24 | _pattern_entry_dir, |
| 25 | _resolutions_dir, |
| 26 | _resolution_path, |
| 27 | _validate_id, |
| 28 | ) |
| 29 | from .patterns import _write_atomic, forget_pattern, list_patterns |
| 30 | from .types import ( |
| 31 | AgentProvenance, |
| 32 | Resolution, |
| 33 | ResolutionStrategy, |
| 34 | _ResolutionDict, |
| 35 | ) |
| 36 | |
| 37 | logger = logging.getLogger(__name__) |
| 38 | |
| 39 | #: Maximum bytes read from a resolution JSON file. |
| 40 | _MAX_RESOLUTION_BYTES: int = 16_384 # 16 KiB |
| 41 | |
| 42 | # --------------------------------------------------------------------------- |
| 43 | # Serialisation helpers |
| 44 | # --------------------------------------------------------------------------- |
| 45 | |
| 46 | def _resolution_to_dict(resolution: Resolution) -> _ResolutionDict: |
| 47 | return _ResolutionDict( |
| 48 | resolution_id=resolution.resolution_id, |
| 49 | pattern_id=resolution.pattern_id, |
| 50 | strategy=resolution.strategy, |
| 51 | policy_id=resolution.policy_id, |
| 52 | outcome_blob=resolution.outcome_blob, |
| 53 | resolved_by=resolution.resolved_by.to_dict(), |
| 54 | human_verified=resolution.human_verified, |
| 55 | confidence=resolution.confidence, |
| 56 | rationale=resolution.rationale, |
| 57 | resolved_at=resolution.resolved_at.isoformat(), |
| 58 | applied_count=resolution.applied_count, |
| 59 | ) |
| 60 | |
| 61 | def _dict_to_resolution(data: Mapping[str, JsonValue]) -> Resolution | None: |
| 62 | """Deserialise a JSON dict to :class:`Resolution`, returning ``None`` on error.""" |
| 63 | try: |
| 64 | return Resolution( |
| 65 | resolution_id=str(data["resolution_id"]), |
| 66 | pattern_id=str(data.get("pattern_id", "")), |
| 67 | strategy=str(data.get("strategy", ResolutionStrategy.MANUAL)), |
| 68 | policy_id=data.get("policy_id") or None, |
| 69 | outcome_blob=str(data.get("outcome_blob", "")), |
| 70 | resolved_by=AgentProvenance.from_dict(data.get("resolved_by") or {}), |
| 71 | human_verified=bool(data.get("human_verified", False)), |
| 72 | confidence=float(data.get("confidence", 0.0)), |
| 73 | rationale=str(data.get("rationale", "")), |
| 74 | resolved_at=_parse_dt(data.get("resolved_at")), |
| 75 | applied_count=int(data.get("applied_count", 0)), |
| 76 | ) |
| 77 | except (KeyError, TypeError, ValueError) as exc: |
| 78 | logger.warning("⚠️ harmony: failed to deserialise resolution: %s", exc) |
| 79 | return None |
| 80 | |
| 81 | # --------------------------------------------------------------------------- |
| 82 | # Resolution CRUD |
| 83 | # --------------------------------------------------------------------------- |
| 84 | |
| 85 | def save_resolution(root: pathlib.Path, resolution: Resolution) -> None: |
| 86 | """Persist a :class:`Resolution` to the harmony store. |
| 87 | |
| 88 | Stored at ``.muse/harmony/patterns/<pattern_id>/resolutions/<resolution_id>.json``. |
| 89 | |
| 90 | **Idempotent** — saving the same resolution_id twice is a no-op. |
| 91 | |
| 92 | Args: |
| 93 | root: Repository root. |
| 94 | resolution: Resolution to save. |
| 95 | |
| 96 | Raises: |
| 97 | ValueError: If either ID fails hex validation. |
| 98 | FileNotFoundError: If the parent pattern does not exist. |
| 99 | Call :func:`record_pattern` first. |
| 100 | """ |
| 101 | _validate_id(resolution.pattern_id, "pattern_id") |
| 102 | _validate_id(resolution.resolution_id, "resolution_id") |
| 103 | |
| 104 | pattern_p = _pattern_entry_dir(root, resolution.pattern_id) / _PATTERN_FILE |
| 105 | if not pattern_p.exists(): |
| 106 | raise FileNotFoundError( |
| 107 | f"No harmony pattern found for pattern_id {resolution.pattern_id}. " |
| 108 | "Call record_pattern() before save_resolution()." |
| 109 | ) |
| 110 | |
| 111 | dest = _resolution_path(root, resolution.pattern_id, resolution.resolution_id) |
| 112 | |
| 113 | if dest.exists(): |
| 114 | logger.debug( |
| 115 | "harmony: resolution %s already saved for pattern %s", |
| 116 | short_id(resolution.resolution_id), |
| 117 | short_id(resolution.pattern_id), |
| 118 | ) |
| 119 | return |
| 120 | |
| 121 | _write_atomic(dest, json.dumps(_resolution_to_dict(resolution), indent=2)) |
| 122 | logger.debug( |
| 123 | "harmony: saved resolution %s for pattern %s " |
| 124 | "(strategy=%s, confidence=%.2f, verified=%s)", |
| 125 | short_id(resolution.resolution_id), |
| 126 | short_id(resolution.pattern_id), |
| 127 | resolution.strategy, |
| 128 | resolution.confidence, |
| 129 | resolution.human_verified, |
| 130 | ) |
| 131 | |
| 132 | def load_resolution( |
| 133 | root: pathlib.Path, |
| 134 | pattern_id: str, |
| 135 | resolution_id: str, |
| 136 | ) -> Resolution | None: |
| 137 | """Load a :class:`Resolution` from the harmony store. |
| 138 | |
| 139 | Returns ``None`` when either ID fails validation, the file does not exist, |
| 140 | it exceeds :data:`_MAX_RESOLUTION_BYTES`, or JSON parsing fails. |
| 141 | |
| 142 | Args: |
| 143 | root: Repository root. |
| 144 | pattern_id: ``sha256:`` content-addressed pattern ID. |
| 145 | resolution_id: ``sha256:`` content-addressed resolution ID. |
| 146 | """ |
| 147 | try: |
| 148 | _validate_id(pattern_id, "pattern_id") |
| 149 | _validate_id(resolution_id, "resolution_id") |
| 150 | except ValueError: |
| 151 | return None |
| 152 | |
| 153 | dest = _resolution_path(root, pattern_id, resolution_id) |
| 154 | if not dest.exists(): |
| 155 | return None |
| 156 | |
| 157 | try: |
| 158 | size = dest.stat().st_size |
| 159 | except OSError as exc: |
| 160 | logger.warning( |
| 161 | "⚠️ harmony: failed to read resolution %s: %s", resolution_id, exc |
| 162 | ) |
| 163 | return None |
| 164 | if size > _MAX_RESOLUTION_BYTES: |
| 165 | logger.warning( |
| 166 | "⚠️ harmony: resolution %s is %d bytes — exceeds %d cap; skipping", |
| 167 | resolution_id, |
| 168 | size, |
| 169 | _MAX_RESOLUTION_BYTES, |
| 170 | ) |
| 171 | return None |
| 172 | data = load_json_file(dest) |
| 173 | if data is None: |
| 174 | logger.warning( |
| 175 | "⚠️ harmony: failed to read resolution %s: unreadable or invalid JSON", |
| 176 | resolution_id, |
| 177 | ) |
| 178 | return None |
| 179 | |
| 180 | return _dict_to_resolution(data) |
| 181 | |
| 182 | def list_resolutions(root: pathlib.Path, pattern_id: str) -> list[Resolution]: |
| 183 | """Return all :class:`Resolution` entries for *pattern_id*. |
| 184 | |
| 185 | Skips symlinks and files that fail to parse. |
| 186 | |
| 187 | Sorted by quality descending: |
| 188 | ``(human_verified, confidence, applied_count)`` — highest quality first. |
| 189 | |
| 190 | Args: |
| 191 | root: Repository root. |
| 192 | pattern_id: ``sha256:`` content-addressed pattern ID. |
| 193 | """ |
| 194 | try: |
| 195 | res_dir = _resolutions_dir(root, pattern_id) |
| 196 | except ValueError: |
| 197 | return [] |
| 198 | |
| 199 | if not res_dir.exists(): |
| 200 | return [] |
| 201 | |
| 202 | resolutions: list[Resolution] = [] |
| 203 | for algo_dir in res_dir.iterdir(): |
| 204 | if algo_dir.is_symlink() or not algo_dir.is_dir(): |
| 205 | continue |
| 206 | for f in algo_dir.iterdir(): |
| 207 | if f.is_symlink() or not f.is_file(): |
| 208 | continue |
| 209 | if not f.name.endswith(".json"): |
| 210 | continue |
| 211 | bare_rid = f.name[:-5] |
| 212 | if not _BARE_HEX64_RE.match(bare_rid): |
| 213 | continue |
| 214 | r = load_resolution(root, pattern_id, long_id(bare_rid, algo_dir.name)) |
| 215 | if r is not None: |
| 216 | resolutions.append(r) |
| 217 | |
| 218 | resolutions.sort( |
| 219 | key=lambda r: (r.human_verified, r.confidence, r.applied_count), |
| 220 | reverse=True, |
| 221 | ) |
| 222 | return resolutions |
| 223 | |
| 224 | def increment_applied_count( |
| 225 | root: pathlib.Path, |
| 226 | pattern_id: str, |
| 227 | resolution_id: str, |
| 228 | ) -> bool: |
| 229 | """Atomically increment the ``applied_count`` of a :class:`Resolution`. |
| 230 | |
| 231 | Loads the current resolution, creates an updated copy via |
| 232 | :func:`dataclasses.replace`, and writes it back atomically. Returns |
| 233 | ``True`` on success, ``False`` if the resolution does not exist. |
| 234 | |
| 235 | Args: |
| 236 | root: Repository root. |
| 237 | pattern_id: ``sha256:`` content-addressed pattern ID. |
| 238 | resolution_id: ``sha256:`` content-addressed resolution ID. |
| 239 | """ |
| 240 | resolution = load_resolution(root, pattern_id, resolution_id) |
| 241 | if resolution is None: |
| 242 | return False |
| 243 | |
| 244 | updated = dc_replace(resolution, applied_count=resolution.applied_count + 1) |
| 245 | dest = _resolution_path(root, pattern_id, resolution_id) |
| 246 | _write_atomic(dest, json.dumps(_resolution_to_dict(updated), indent=2)) |
| 247 | logger.debug( |
| 248 | "harmony: applied_count → %d for resolution %s", |
| 249 | updated.applied_count, |
| 250 | short_id(resolution_id), |
| 251 | ) |
| 252 | return True |
| 253 | |
| 254 | def best_resolution(root: pathlib.Path, pattern_id: str) -> Resolution | None: |
| 255 | """Return the highest-quality :class:`Resolution` for *pattern_id*. |
| 256 | |
| 257 | Quality ranking: ``human_verified`` > ``confidence`` > ``applied_count``. |
| 258 | |
| 259 | Returns ``None`` if no resolutions exist for the pattern. |
| 260 | |
| 261 | Args: |
| 262 | root: Repository root. |
| 263 | pattern_id: ``sha256:`` content-addressed pattern ID. |
| 264 | """ |
| 265 | resolutions = list_resolutions(root, pattern_id) |
| 266 | return resolutions[0] if resolutions else None |
| 267 | |
| 268 | # --------------------------------------------------------------------------- |
| 269 | # GC |
| 270 | # --------------------------------------------------------------------------- |
| 271 | |
| 272 | def gc_stale(root: pathlib.Path, age_days: int = 90) -> int: |
| 273 | """Remove patterns that have no resolution and are older than *age_days*. |
| 274 | |
| 275 | Patterns with at least one saved resolution are retained regardless of age. |
| 276 | |
| 277 | Args: |
| 278 | root: Repository root. |
| 279 | age_days: Age threshold in days (default 90). |
| 280 | |
| 281 | Returns: |
| 282 | Number of patterns removed. |
| 283 | """ |
| 284 | cutoff = _now_utc() - datetime.timedelta(days=age_days) |
| 285 | removed = 0 |
| 286 | |
| 287 | for p in list_patterns(root): |
| 288 | res_dir = _resolutions_dir(root, p.pattern_id) |
| 289 | has_any_resolution = ( |
| 290 | res_dir.exists() |
| 291 | and any( |
| 292 | True |
| 293 | for algo_dir in res_dir.iterdir() |
| 294 | if not algo_dir.is_symlink() and algo_dir.is_dir() |
| 295 | for f in algo_dir.iterdir() |
| 296 | if not f.is_symlink() and f.is_file() and f.name.endswith(".json") |
| 297 | ) |
| 298 | ) |
| 299 | if has_any_resolution: |
| 300 | continue |
| 301 | if p.recorded_at < cutoff: |
| 302 | if forget_pattern(root, p.pattern_id): |
| 303 | removed += 1 |
| 304 | |
| 305 | logger.debug( |
| 306 | "harmony gc: removed %d stale unresolved patterns (age_days=%d)", |
| 307 | removed, |
| 308 | age_days, |
| 309 | ) |
| 310 | return removed |
File History
1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
7 days ago