promty-collector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,696 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+ import difflib
7
+ import fnmatch
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import subprocess
13
+ from typing import Any
14
+ from uuid import uuid4
15
+
16
+ from events import BaseEvent
17
+ from file_lock import locked_file
18
+ from git_context import normalize_github_url
19
+ from payloads import TURN_ID_KEYS, get_first_value
20
+
21
+ DEFAULT_CHANGE_BASELINE_PATH = Path(
22
+ os.environ.get("PROMPTHUB_CHANGE_BASELINE_PATH", "~/.prompthub/change-baselines.json")
23
+ ).expanduser()
24
+ GIT_TIMEOUT_SECONDS = float(os.environ.get("PROMPTHUB_GIT_TIMEOUT", "5"))
25
+ FINGERPRINT_MAX_BYTES = int(os.environ.get("PROMPTHUB_FINGERPRINT_MAX_BYTES", "2097152"))
26
+ PATCH_CONTENT_MAX_BYTES = int(os.environ.get("PROMPTHUB_PATCH_CONTENT_MAX_BYTES", "524288"))
27
+ PATCH_MAX_BYTES = int(os.environ.get("PROMPTHUB_PATCH_MAX_BYTES", "262144"))
28
+ UNTRACKED_LINE_COUNT_MAX_BYTES = int(
29
+ os.environ.get("PROMPTHUB_UNTRACKED_LINE_COUNT_MAX_BYTES", "1048576")
30
+ )
31
+ BASELINE_TTL_HOURS = float(os.environ.get("PROMPTHUB_BASELINE_TTL_HOURS", "24"))
32
+ CONSUMED_BASELINE_TTL_HOURS = float(
33
+ os.environ.get("PROMPTHUB_CONSUMED_BASELINE_TTL_HOURS", "1")
34
+ )
35
+ BASELINE_MAX_RECORDS = int(os.environ.get("PROMPTHUB_BASELINE_MAX_RECORDS", "500"))
36
+ PATCH_EXCLUDED_DIRS = {
37
+ ".git",
38
+ ".hg",
39
+ ".svn",
40
+ ".venv",
41
+ "__pycache__",
42
+ "build",
43
+ "coverage",
44
+ "dist",
45
+ "node_modules",
46
+ }
47
+ PATCH_SENSITIVE_PATTERNS = (
48
+ ".env",
49
+ ".env.*",
50
+ "*.key",
51
+ "*.pem",
52
+ "*.p12",
53
+ "*.pfx",
54
+ "*id_rsa*",
55
+ "*secret*",
56
+ "*token*",
57
+ )
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ChangeDetectionResult:
62
+ baseline: dict[str, Any]
63
+ current: dict[str, Any]
64
+ payload: dict[str, Any]
65
+
66
+
67
+ def utc_now_iso() -> str:
68
+ return datetime.now(timezone.utc).isoformat()
69
+
70
+
71
+ def _parse_datetime(value: Any) -> datetime | None:
72
+ if not isinstance(value, str) or not value:
73
+ return None
74
+ try:
75
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
76
+ except ValueError:
77
+ return None
78
+ if parsed.tzinfo is None:
79
+ return parsed.replace(tzinfo=timezone.utc)
80
+ return parsed
81
+
82
+
83
+ def _run_git(args: list[str], cwd: str | Path, timeout: float = GIT_TIMEOUT_SECONDS) -> str | None:
84
+ try:
85
+ result = subprocess.run(
86
+ ["git", *args],
87
+ cwd=str(cwd),
88
+ check=False,
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=timeout,
92
+ )
93
+ except (OSError, subprocess.TimeoutExpired):
94
+ return None
95
+
96
+ if result.returncode != 0:
97
+ return None
98
+ return result.stdout
99
+
100
+
101
+ def _run_git_bytes(
102
+ args: list[str],
103
+ cwd: str | Path,
104
+ timeout: float = GIT_TIMEOUT_SECONDS,
105
+ ) -> bytes | None:
106
+ try:
107
+ result = subprocess.run(
108
+ ["git", *args],
109
+ cwd=str(cwd),
110
+ check=False,
111
+ capture_output=True,
112
+ timeout=timeout,
113
+ )
114
+ except (OSError, subprocess.TimeoutExpired):
115
+ return None
116
+
117
+ if result.returncode != 0:
118
+ return None
119
+ return result.stdout
120
+
121
+
122
+ def resolve_git_root(cwd: str | Path | None) -> str | None:
123
+ start = Path(cwd or os.getcwd()).expanduser()
124
+ output = _run_git(["rev-parse", "--show-toplevel"], start)
125
+ if output is None:
126
+ return None
127
+ return output.strip() or None
128
+
129
+
130
+ def _patch_omitted_reason(path: str) -> str | None:
131
+ parts = [part for part in path.replace("\\", "/").split("/") if part]
132
+ if any(part in PATCH_EXCLUDED_DIRS for part in parts):
133
+ return "excluded_path"
134
+ name = parts[-1] if parts else path
135
+ lowered_path = path.lower()
136
+ lowered_name = name.lower()
137
+ for pattern in PATCH_SENSITIVE_PATTERNS:
138
+ if fnmatch.fnmatch(lowered_name, pattern) or fnmatch.fnmatch(lowered_path, pattern):
139
+ return "sensitive_path"
140
+ return None
141
+
142
+
143
+ def _decode_text(data: bytes | None) -> str | None:
144
+ if data is None or b"\0" in data or len(data) > PATCH_CONTENT_MAX_BYTES:
145
+ return None
146
+ try:
147
+ return data.decode("utf-8")
148
+ except UnicodeDecodeError:
149
+ return None
150
+
151
+
152
+ def _read_worktree_text(git_root: str, path: str) -> str | None:
153
+ if _patch_omitted_reason(path):
154
+ return None
155
+ file_path = Path(git_root) / path
156
+ try:
157
+ stat = file_path.stat()
158
+ except OSError:
159
+ return None
160
+ if not file_path.is_file() or stat.st_size > PATCH_CONTENT_MAX_BYTES:
161
+ return None
162
+ try:
163
+ return _decode_text(file_path.read_bytes())
164
+ except OSError:
165
+ return None
166
+
167
+
168
+ def _git_show_text(git_root: str, commit: str | None, path: str) -> str | None:
169
+ if not commit or _patch_omitted_reason(path):
170
+ return None
171
+ return _decode_text(_run_git_bytes(["show", f"{commit}:{path}"], git_root))
172
+
173
+
174
+ def _parse_num(value: str) -> int | None:
175
+ if value == "-":
176
+ return None
177
+ try:
178
+ return int(value)
179
+ except ValueError:
180
+ return None
181
+
182
+
183
+ def _parse_numstat(output: str | None) -> dict[str, dict[str, Any]]:
184
+ if not output:
185
+ return {}
186
+
187
+ entries: dict[str, dict[str, Any]] = {}
188
+ for line in output.splitlines():
189
+ parts = line.split("\t")
190
+ if len(parts) < 3:
191
+ continue
192
+ insertions = _parse_num(parts[0])
193
+ deletions = _parse_num(parts[1])
194
+ path = parts[-1]
195
+ entries[path] = {
196
+ "insertions": insertions,
197
+ "deletions": deletions,
198
+ "binary": insertions is None or deletions is None,
199
+ }
200
+ return entries
201
+
202
+
203
+ def _parse_status(output: str | None) -> dict[str, dict[str, Any]]:
204
+ if not output:
205
+ return {}
206
+
207
+ entries: dict[str, dict[str, Any]] = {}
208
+ for line in output.splitlines():
209
+ if len(line) < 4:
210
+ continue
211
+ code = line[:2]
212
+ path = line[3:]
213
+ old_path = None
214
+ if " -> " in path:
215
+ old_path, path = path.split(" -> ", 1)
216
+ entries[path] = {
217
+ "code": code,
218
+ "old_path": old_path,
219
+ }
220
+ return entries
221
+
222
+
223
+ def _file_fingerprint(path: Path) -> dict[str, Any] | None:
224
+ if not path.exists() or not path.is_file():
225
+ return None
226
+
227
+ try:
228
+ stat = path.stat()
229
+ except OSError:
230
+ return None
231
+
232
+ fingerprint: dict[str, Any] = {
233
+ "size": stat.st_size,
234
+ }
235
+ if stat.st_size > FINGERPRINT_MAX_BYTES:
236
+ fingerprint["mtime_ns"] = stat.st_mtime_ns
237
+ fingerprint["truncated"] = True
238
+ return fingerprint
239
+
240
+ try:
241
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
242
+ except OSError:
243
+ return fingerprint
244
+
245
+ fingerprint["sha256"] = digest
246
+ return fingerprint
247
+
248
+
249
+ def _count_text_lines(path: Path) -> int | None:
250
+ try:
251
+ stat = path.stat()
252
+ except OSError:
253
+ return None
254
+
255
+ if stat.st_size > UNTRACKED_LINE_COUNT_MAX_BYTES:
256
+ return None
257
+
258
+ try:
259
+ data = path.read_bytes()
260
+ except OSError:
261
+ return None
262
+
263
+ if b"\0" in data:
264
+ return None
265
+ if not data:
266
+ return 0
267
+ return data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
268
+
269
+
270
+ def capture_git_snapshot(cwd: str | Path | None) -> dict[str, Any] | None:
271
+ git_root = resolve_git_root(cwd)
272
+ if git_root is None:
273
+ return None
274
+
275
+ status = _parse_status(
276
+ _run_git(["status", "--porcelain=v1", "--untracked-files=all"], git_root)
277
+ )
278
+ numstat = _parse_numstat(_run_git(["diff", "--numstat", "HEAD", "--"], git_root))
279
+ signatures: dict[str, dict[str, Any] | None] = {}
280
+ contents: dict[str, dict[str, Any]] = {}
281
+
282
+ for path, entry in status.items():
283
+ signatures[path] = _file_fingerprint(Path(git_root) / path)
284
+ text = _read_worktree_text(git_root, path)
285
+ if text is not None:
286
+ contents[path] = {
287
+ "content": text,
288
+ "captured_at": utc_now_iso(),
289
+ }
290
+ if entry.get("code") == "??" and path not in numstat:
291
+ line_count = _count_text_lines(Path(git_root) / path)
292
+ numstat[path] = {
293
+ "insertions": line_count,
294
+ "deletions": 0 if line_count is not None else None,
295
+ "binary": line_count is None,
296
+ }
297
+
298
+ return {
299
+ "git_root": git_root,
300
+ "head_commit": (_run_git(["rev-parse", "HEAD"], git_root) or "").strip() or None,
301
+ "branch": (_run_git(["branch", "--show-current"], git_root) or "").strip() or None,
302
+ "git_remote": (_run_git(["remote", "get-url", "origin"], git_root) or "").strip() or None,
303
+ "status": status,
304
+ "numstat": numstat,
305
+ "signatures": signatures,
306
+ "contents": contents,
307
+ "captured_at": utc_now_iso(),
308
+ }
309
+
310
+
311
+ def _status_from_code(code: str | None, baseline_present: bool) -> str:
312
+ if code is None:
313
+ return "cleaned" if baseline_present else "modified"
314
+ if code == "??":
315
+ return "added"
316
+ if "R" in code:
317
+ return "renamed"
318
+ if "D" in code:
319
+ return "deleted"
320
+ if "A" in code:
321
+ return "added"
322
+ return "modified"
323
+
324
+
325
+ def _metric_delta(
326
+ current: dict[str, Any] | None,
327
+ baseline: dict[str, Any] | None,
328
+ key: str,
329
+ ) -> int | None:
330
+ current_value = (current or {}).get(key)
331
+ baseline_value = (baseline or {}).get(key)
332
+ if current_value is None and baseline_value is None:
333
+ return None
334
+ return (current_value or 0) - (baseline_value or 0)
335
+
336
+
337
+ def _snapshot_content(snapshot: dict[str, Any], path: str | None) -> str | None:
338
+ if not path:
339
+ return None
340
+ contents = snapshot.get("contents")
341
+ if not isinstance(contents, dict):
342
+ return None
343
+ entry = contents.get(path)
344
+ if not isinstance(entry, dict):
345
+ return None
346
+ content = entry.get("content")
347
+ return content if isinstance(content, str) else None
348
+
349
+
350
+ def _before_text(
351
+ baseline_snapshot: dict[str, Any],
352
+ path: str,
353
+ old_path: str | None,
354
+ ) -> str | None:
355
+ baseline_status = baseline_snapshot.get("status") or {}
356
+ baseline_entry = baseline_status.get(path) or (baseline_status.get(old_path) if old_path else None)
357
+ baseline_code = (baseline_entry or {}).get("code")
358
+ if baseline_code and ("A" in baseline_code or baseline_code == "??"):
359
+ return _snapshot_content(baseline_snapshot, path) or _snapshot_content(baseline_snapshot, old_path)
360
+
361
+ snapshot_text = _snapshot_content(baseline_snapshot, path) or _snapshot_content(baseline_snapshot, old_path)
362
+ if snapshot_text is not None:
363
+ return snapshot_text
364
+
365
+ git_root = baseline_snapshot.get("git_root")
366
+ if not isinstance(git_root, str):
367
+ return None
368
+ return _git_show_text(git_root, baseline_snapshot.get("head_commit"), old_path or path)
369
+
370
+
371
+ def _after_text(current_snapshot: dict[str, Any], path: str, status: str) -> str | None:
372
+ if status == "deleted":
373
+ return None
374
+ git_root = current_snapshot.get("git_root")
375
+ if not isinstance(git_root, str):
376
+ return None
377
+ return _read_worktree_text(git_root, path)
378
+
379
+
380
+ def _build_patch(
381
+ *,
382
+ after: str | None,
383
+ before: str | None,
384
+ old_path: str | None,
385
+ path: str,
386
+ ) -> tuple[str | None, bool]:
387
+ before_lines = [] if before is None else before.splitlines(keepends=True)
388
+ after_lines = [] if after is None else after.splitlines(keepends=True)
389
+ patch = "".join(
390
+ difflib.unified_diff(
391
+ before_lines,
392
+ after_lines,
393
+ fromfile=f"a/{old_path or path}",
394
+ tofile=f"b/{path}",
395
+ )
396
+ )
397
+ if not patch:
398
+ return None, False
399
+ encoded = patch.encode("utf-8")
400
+ if len(encoded) <= PATCH_MAX_BYTES:
401
+ return patch, False
402
+ truncated = encoded[:PATCH_MAX_BYTES].decode("utf-8", errors="ignore")
403
+ return f"{truncated}\n\n[Promty patch truncated]", True
404
+
405
+
406
+ def _attach_patch(
407
+ change: dict[str, Any],
408
+ *,
409
+ baseline_snapshot: dict[str, Any],
410
+ current_snapshot: dict[str, Any],
411
+ ) -> dict[str, Any]:
412
+ path = change["path"]
413
+ old_path = change.get("old_path")
414
+ reason = _patch_omitted_reason(path)
415
+ if reason is not None:
416
+ change["patch_omitted_reason"] = reason
417
+ return change
418
+ if change.get("binary") is True:
419
+ change["patch_omitted_reason"] = "binary"
420
+ return change
421
+
422
+ before = _before_text(baseline_snapshot, path, old_path if isinstance(old_path, str) else None)
423
+ after = _after_text(current_snapshot, path, str(change.get("status") or "modified"))
424
+ if before is None and after is None:
425
+ change["patch_omitted_reason"] = "content_unavailable"
426
+ return change
427
+
428
+ patch, truncated = _build_patch(before=before, after=after, old_path=old_path, path=path)
429
+ if patch:
430
+ change["patch"] = patch
431
+ change["patch_truncated"] = truncated
432
+ else:
433
+ change["patch_omitted_reason"] = "empty_patch"
434
+ return change
435
+
436
+
437
+ def _changed_paths(
438
+ baseline_snapshot: dict[str, Any],
439
+ current_snapshot: dict[str, Any],
440
+ ) -> list[dict[str, Any]]:
441
+ baseline_status = baseline_snapshot.get("status") or {}
442
+ current_status = current_snapshot.get("status") or {}
443
+ baseline_numstat = baseline_snapshot.get("numstat") or {}
444
+ current_numstat = current_snapshot.get("numstat") or {}
445
+ baseline_signatures = baseline_snapshot.get("signatures") or {}
446
+ current_signatures = current_snapshot.get("signatures") or {}
447
+ paths = sorted(
448
+ set(baseline_status)
449
+ | set(current_status)
450
+ | set(baseline_numstat)
451
+ | set(current_numstat)
452
+ | set(baseline_signatures)
453
+ | set(current_signatures)
454
+ )
455
+
456
+ changes: list[dict[str, Any]] = []
457
+ for path in paths:
458
+ before_status = baseline_status.get(path)
459
+ after_status = current_status.get(path)
460
+ before_numstat = baseline_numstat.get(path)
461
+ after_numstat = current_numstat.get(path)
462
+ before_signature = baseline_signatures.get(path)
463
+ after_signature = current_signatures.get(path)
464
+
465
+ if (
466
+ before_status == after_status
467
+ and before_numstat == after_numstat
468
+ and before_signature == after_signature
469
+ ):
470
+ continue
471
+
472
+ insertions_delta = _metric_delta(after_numstat, before_numstat, "insertions")
473
+ deletions_delta = _metric_delta(after_numstat, before_numstat, "deletions")
474
+ change = {
475
+ "path": path,
476
+ "status": _status_from_code(
477
+ (after_status or {}).get("code"),
478
+ baseline_present=before_status is not None or before_numstat is not None,
479
+ ),
480
+ "old_path": (after_status or before_status or {}).get("old_path"),
481
+ "before_path": (after_status or before_status or {}).get("old_path"),
482
+ "git_status": (after_status or {}).get("code"),
483
+ "insertions": (after_numstat or {}).get("insertions"),
484
+ "deletions": (after_numstat or {}).get("deletions"),
485
+ "insertions_delta": insertions_delta,
486
+ "deletions_delta": deletions_delta,
487
+ "additions": insertions_delta,
488
+ "binary": bool((after_numstat or {}).get("binary")),
489
+ }
490
+ if deletions_delta is not None:
491
+ change["removals"] = deletions_delta
492
+ change = _attach_patch(
493
+ change,
494
+ baseline_snapshot=baseline_snapshot,
495
+ current_snapshot=current_snapshot,
496
+ )
497
+ changes.append({key: value for key, value in change.items() if value is not None})
498
+
499
+ return changes
500
+
501
+
502
+ def _summarize_changes(changes: list[dict[str, Any]]) -> dict[str, Any]:
503
+ summary = {
504
+ "total": len(changes),
505
+ "files_changed": len(changes),
506
+ "files": len(changes),
507
+ "added": 0,
508
+ "modified": 0,
509
+ "deleted": 0,
510
+ "renamed": 0,
511
+ "cleaned": 0,
512
+ "additions": 0,
513
+ "deletions": 0,
514
+ "insertions_delta": 0,
515
+ "deletions_delta": 0,
516
+ }
517
+
518
+ for change in changes:
519
+ status = change.get("status")
520
+ if status in summary:
521
+ summary[status] += 1
522
+ additions = change.get("insertions_delta") or 0
523
+ deletions = change.get("deletions_delta") or 0
524
+ summary["additions"] += additions
525
+ summary["deletions"] += deletions
526
+ summary["insertions_delta"] += additions
527
+ summary["deletions_delta"] += deletions
528
+ return summary
529
+
530
+
531
+ def detect_changes(record: dict[str, Any], cwd: str | Path | None = None) -> ChangeDetectionResult | None:
532
+ baseline_snapshot = record.get("snapshot")
533
+ if not isinstance(baseline_snapshot, dict):
534
+ return None
535
+
536
+ current_snapshot = capture_git_snapshot(cwd or record.get("cwd") or baseline_snapshot.get("git_root"))
537
+ if current_snapshot is None:
538
+ return None
539
+
540
+ if current_snapshot.get("git_root") != baseline_snapshot.get("git_root"):
541
+ return None
542
+
543
+ changes = _changed_paths(baseline_snapshot, current_snapshot)
544
+ payload = {
545
+ "files": [change["path"] for change in changes],
546
+ "cwd": record.get("cwd"),
547
+ "session_id": record.get("external_session_id"),
548
+ "prompt_event_id": record.get("prompt_event_id"),
549
+ "turn_id": record.get("turn_id"),
550
+ "git_root": baseline_snapshot.get("git_root"),
551
+ "branch": current_snapshot.get("branch") or baseline_snapshot.get("branch"),
552
+ "git_remote": current_snapshot.get("git_remote") or baseline_snapshot.get("git_remote"),
553
+ "github_url": normalize_github_url(
554
+ current_snapshot.get("git_remote") or baseline_snapshot.get("git_remote")
555
+ ),
556
+ "base_commit": baseline_snapshot.get("head_commit"),
557
+ "head_commit": current_snapshot.get("head_commit"),
558
+ "baseline_captured_at": baseline_snapshot.get("captured_at"),
559
+ "detected_at": current_snapshot.get("captured_at"),
560
+ "source": "git",
561
+ "summary": _summarize_changes(changes),
562
+ "changes": changes,
563
+ "change_detection_complete": True,
564
+ "no_changes": not changes,
565
+ }
566
+ return ChangeDetectionResult(
567
+ baseline=record,
568
+ current=current_snapshot,
569
+ payload={key: value for key, value in payload.items() if value is not None},
570
+ )
571
+
572
+
573
+ class ChangeBaselineStore:
574
+ def __init__(self, path: str | Path | None = None) -> None:
575
+ self.path = Path(path).expanduser() if path else DEFAULT_CHANGE_BASELINE_PATH
576
+ self.lock_path = self.path.with_suffix(f"{self.path.suffix}.lock")
577
+
578
+ def observe_prompt(
579
+ self,
580
+ *,
581
+ tool: str,
582
+ event: BaseEvent,
583
+ raw_payload: dict[str, Any],
584
+ external_session_id: str | None,
585
+ cwd: str | None,
586
+ ) -> None:
587
+ snapshot = capture_git_snapshot(cwd)
588
+ if snapshot is None:
589
+ return
590
+
591
+ record = {
592
+ "id": event.id,
593
+ "tool": tool,
594
+ "prompt_event_id": event.id,
595
+ "project_id": event.project_id,
596
+ "session_id": event.session_id,
597
+ "external_session_id": external_session_id,
598
+ "turn_id": get_first_value(raw_payload, TURN_ID_KEYS),
599
+ "cwd": cwd,
600
+ "created_at": utc_now_iso(),
601
+ "consumed_at": None,
602
+ "snapshot": snapshot,
603
+ }
604
+ with self._locked():
605
+ data = self._read()
606
+ data.setdefault("records", {})[event.id] = record
607
+ self._prune(data)
608
+ self._write(data)
609
+
610
+ def find_latest(
611
+ self,
612
+ *,
613
+ tool: str,
614
+ external_session_id: str | None,
615
+ cwd: str | None,
616
+ ) -> dict[str, Any] | None:
617
+ git_root = resolve_git_root(cwd) if cwd else None
618
+ with self._locked():
619
+ records = list((self._read().get("records") or {}).values())
620
+
621
+ candidates: list[dict[str, Any]] = []
622
+ for record in records:
623
+ if not isinstance(record, dict) or record.get("consumed_at"):
624
+ continue
625
+ if record.get("tool") != tool:
626
+ continue
627
+ if external_session_id and record.get("external_session_id") == external_session_id:
628
+ candidates.append(record)
629
+ continue
630
+ record_git_root = ((record.get("snapshot") or {}).get("git_root"))
631
+ if git_root and record_git_root == git_root:
632
+ candidates.append(record)
633
+
634
+ candidates.sort(key=lambda record: str(record.get("created_at") or ""))
635
+ return candidates[-1] if candidates else None
636
+
637
+ def mark_consumed(self, record_id: str) -> None:
638
+ with self._locked():
639
+ data = self._read()
640
+ record = (data.get("records") or {}).get(record_id)
641
+ if isinstance(record, dict):
642
+ record["consumed_at"] = utc_now_iso()
643
+ self._prune(data)
644
+ self._write(data)
645
+
646
+ def _prune(self, data: dict[str, Any]) -> None:
647
+ records = data.get("records")
648
+ if not isinstance(records, dict):
649
+ data["records"] = {}
650
+ return
651
+
652
+ now = datetime.now(timezone.utc)
653
+ baseline_cutoff = now - timedelta(hours=BASELINE_TTL_HOURS)
654
+ consumed_cutoff = now - timedelta(hours=CONSUMED_BASELINE_TTL_HOURS)
655
+
656
+ retained: list[tuple[str, dict[str, Any], datetime]] = []
657
+ for record_id, record in records.items():
658
+ if not isinstance(record_id, str) or not isinstance(record, dict):
659
+ continue
660
+ created_at = _parse_datetime(record.get("created_at")) or now
661
+ consumed_at = _parse_datetime(record.get("consumed_at"))
662
+ if consumed_at is not None and consumed_at < consumed_cutoff:
663
+ continue
664
+ if consumed_at is None and created_at < baseline_cutoff:
665
+ continue
666
+ retained.append((record_id, record, created_at))
667
+
668
+ retained.sort(key=lambda item: item[2])
669
+ if len(retained) > BASELINE_MAX_RECORDS:
670
+ retained = retained[-BASELINE_MAX_RECORDS:]
671
+ data["records"] = {record_id: record for record_id, record, _ in retained}
672
+
673
+ @contextmanager
674
+ def _locked(self):
675
+ with locked_file(self.lock_path):
676
+ yield
677
+
678
+ def _read(self) -> dict[str, Any]:
679
+ if not self.path.exists():
680
+ return {"records": {}}
681
+ try:
682
+ with self.path.open("r", encoding="utf-8") as file:
683
+ data = json.load(file)
684
+ except (OSError, json.JSONDecodeError):
685
+ return {"records": {}}
686
+ if not isinstance(data, dict):
687
+ return {"records": {}}
688
+ data.setdefault("records", {})
689
+ return data
690
+
691
+ def _write(self, data: dict[str, Any]) -> None:
692
+ self.path.parent.mkdir(parents=True, exist_ok=True)
693
+ tmp_path = self.path.with_suffix(f"{self.path.suffix}.{uuid4()}.tmp")
694
+ with tmp_path.open("w", encoding="utf-8") as file:
695
+ json.dump(data, file, ensure_ascii=False)
696
+ tmp_path.replace(self.path)