gabriel / muse public
resolutions.py python
310 lines 10.3 KB
Raw
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