cctally 1.93.0 → 1.94.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.
@@ -251,6 +251,24 @@ def _resolve_display_tz_obj(config: dict) -> ZoneInfo:
251
251
  return ZoneInfo("Etc/UTC")
252
252
 
253
253
 
254
+ def resolve_display_tz_name(raw: "str | None") -> str:
255
+ """Resolve a display-tz TOKEN to a concrete IANA zone name.
256
+
257
+ ``local`` and ``utc`` are configuration tokens, not zone names. A share
258
+ artifact states the zone its dates are in, and neither token names one:
259
+ ``(local)`` tells the reader nothing, and ``utc`` is not loadable as a
260
+ ``ZoneInfo`` key on a case-sensitive filesystem. #503 S2 D7 requires the
261
+ resolved concrete zone, so every share entry point routes its token
262
+ through here before it reaches a ``PeriodSpec``.
263
+
264
+ Delegates to ``_resolve_display_tz_obj`` rather than re-deriving the
265
+ host zone, so the ``local``-fallback warning and the malformed-value
266
+ behavior stay in one place. Returns ``ZoneInfo.key``, which is always a
267
+ loadable IANA name.
268
+ """
269
+ return _resolve_display_tz_obj({"display": {"tz": raw}}).key
270
+
271
+
254
272
  def _apply_display_tz_override(
255
273
  config: dict,
256
274
  override: "str | None",
@@ -311,7 +311,13 @@ class DoctorState:
311
311
  # and the cursor's segment, for the verbose detail.
312
312
  # * journal_heal_incidents — most-recent-first list of the auto-heal
313
313
  # artifacts (quarantine/ dirs + logs/<db>-corruption-forensics-*.json);
314
- # each dict carries {kind, name, age_s}. None = the dirs were unreadable.
314
+ # each dict carries {kind, name, age_s, shape}. `shape` is the
315
+ # incident manifest's `damage.preserved.shapeToken` and is None for a
316
+ # bundle. None = the dirs were unreadable.
317
+ # * journal_heal_detections (#496 S6 §7.2) — the durable heal-ring
318
+ # entries, each {heal_id, age_s}. A DETECTION is a ring entry keyed by
319
+ # `healId`; an INCIDENT is a quarantine directory and nothing else; a
320
+ # forensics bundle is linked evidence and is neither.
315
321
  journal_present: bool = False
316
322
  journal_appendable: Optional[bool] = None
317
323
  journal_segment_count: int = 0
@@ -322,6 +328,13 @@ class DoctorState:
322
328
  journal_hw_segment: Optional[str] = None
323
329
  journal_cursor_segment: Optional[str] = None
324
330
  journal_heal_incidents: Optional[list] = None
331
+ journal_heal_detections: Optional[list] = None
332
+ # * retained_artifacts (#496 S6 §7.3) — the read-only retention scan
333
+ # and plan: policy status, retained/reclaimable/protected bytes,
334
+ # free disk, the partial-scan flag, the unsatisfied bounds, what
335
+ # the damage-shape floor kept, and any stuck reclaim record. None =
336
+ # the scan could not run, which DEGRADES rather than failing.
337
+ retained_artifacts: Optional[dict] = None
325
338
  # * journal_writer_guard (#386) — the stats sole-writer guard's log
326
339
  # (logs/stats-writer-guard.log). `None` = the log is absent (the normal
327
340
  # state, and NOT an error); otherwise
@@ -2181,11 +2194,15 @@ def _check_db_integrity(s: DoctorState) -> CheckResult:
2181
2194
  id="db.integrity", title="Integrity", severity="fail",
2182
2195
  summary=f"stats.db quick_check: {s.stats_db_quick_check}",
2183
2196
  remediation=(
2184
- "stats.db (the non-re-derivable DB) reports corruption. "
2185
- "Stop the dashboard and other cctally processes, then run "
2186
- "`cctally db repair --db stats --yes`. The command preserves "
2187
- "a backup of the corrupt original before replacing anything. "
2188
- "Do not copy, restore, move, or delete the live DB by hand."),
2197
+ "stats.db reports corruption. With retained journal data it is "
2198
+ "a disposable index: run `cctally db rebuild --db stats` to "
2199
+ "rebuild it from the journal, which loses nothing. On a "
2200
+ "pre-cutover install with no retained journal data it may be "
2201
+ "the only copy of your recorded history — stop the dashboard "
2202
+ "and other cctally processes, then run `cctally db repair --db "
2203
+ "stats --yes`, which preserves a backup of the corrupt original "
2204
+ "before replacing anything. Do not copy, restore, move, or "
2205
+ "delete the live DB by hand."),
2189
2206
  details=details,
2190
2207
  )
2191
2208
  if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
@@ -2362,6 +2379,234 @@ def _check_db_conversations_reclaimable(s: DoctorState) -> CheckResult:
2362
2379
  )
2363
2380
 
2364
2381
 
2382
+ #: A stuck reclaim entry has persisted past this, so no pass will clear it.
2383
+ _RECLAIM_STUCK_AFTER_SECONDS = 24 * 3600
2384
+
2385
+
2386
+ def _gib_text(value) -> str:
2387
+ """One decimal, through the retention kernel's single formatter."""
2388
+ import _lib_artifact_retention
2389
+
2390
+ return _lib_artifact_retention.format_disk_bytes(value, digits=1)
2391
+
2392
+
2393
+ def _int_or_none(value) -> "Optional[int]":
2394
+ """`int(value)` when the gather measured it, `None` when it did not.
2395
+
2396
+ Coercing an unmeasured figure to zero is worse than leaving it out: a
2397
+ consumer reading `details.retainedBytes` would report nothing retained on
2398
+ an install holding gigabytes.
2399
+ """
2400
+ return None if value is None else int(value)
2401
+
2402
+
2403
+ def _bound_phrase(rule: str, state: dict) -> str:
2404
+ """Name one unsatisfied retention bound the way an operator sets it.
2405
+
2406
+ The FAIL summary used to render the byte budget for EVERY member of
2407
+ `unsatisfied_rules`, so a corpus blocked on the 30-day age bound printed
2408
+ "holds it over the 4096 MiB budget" while sitting well inside that budget.
2409
+ Each bound therefore names its own configured value, and degrades to a
2410
+ value-free phrase when the gather could not read it.
2411
+ """
2412
+ if rule == "max_age_seconds":
2413
+ seconds = state.get("max_age_seconds")
2414
+ days = None if seconds is None else max(int(seconds) // 86400, 0)
2415
+ return f"the {days}-day age bound" if days else "its age bound"
2416
+ if rule == "max_count_per_family":
2417
+ count = state.get("max_count_per_family")
2418
+ return (
2419
+ f"the {int(count)} per family count bound" if count
2420
+ else "its per-family count bound"
2421
+ )
2422
+ if rule == "max_total_bytes":
2423
+ budget = state.get("max_total_bytes")
2424
+ return (
2425
+ f"the {int(budget) // (1024 ** 2)} MiB budget" if budget
2426
+ else "its size budget"
2427
+ )
2428
+ if rule == "min_free_bytes":
2429
+ floor = state.get("min_free_bytes")
2430
+ return (
2431
+ f"the {int(floor) // (1024 ** 2)} MiB free-disk floor" if floor
2432
+ else "its free-disk floor"
2433
+ )
2434
+ return rule
2435
+
2436
+
2437
+ def _bound_phrases(rules, state: dict) -> str:
2438
+ phrases = [_bound_phrase(rule, state) for rule in rules]
2439
+ if not phrases:
2440
+ return "its retention policy"
2441
+ if len(phrases) == 1:
2442
+ return phrases[0]
2443
+ return ", ".join(phrases[:-1]) + f" and {phrases[-1]}"
2444
+
2445
+
2446
+ def _check_db_retained_artifacts(s: DoctorState) -> CheckResult:
2447
+ """Retained corruption evidence against the retention policy (#496 S6 §7.3).
2448
+
2449
+ Owns retained bytes across quarantine, logs and the backup families;
2450
+ reclaimable bytes; protected bytes; free disk; the stuck-reclaim condition;
2451
+ and the malformed-policy condition.
2452
+
2453
+ **The damage-shape floor is information, not a failure.** §3.6 excuses it
2454
+ from `unsatisfied_rules` precisely so this leg cannot FAIL over it: two of
2455
+ the maintainer's four damage shapes have exactly one example, so once those
2456
+ age past the bound a floor-driven FAIL would be permanent and no action
2457
+ would clear it. A FAIL an operator cannot resolve trains them to ignore
2458
+ doctor, which defeats F14 — the finding this leg exists to fix.
2459
+
2460
+ **An unavailable scan WARNs; a `deep`-gated skip does not.** §7.5 already
2461
+ degrades a partial scan to `warn`, and a scan that produced nothing at all
2462
+ is strictly worse than one that produced part of the corpus, so it cannot
2463
+ be quieter. At `ok` the leg is skipped by `render_text` under `--quiet` and
2464
+ `doctor` exits 0, which removes the only visibility into the retained
2465
+ corpus and into a stuck reclaim record. A shallow gather is different: it
2466
+ did not try, and it says so, exactly as `db.integrity` does when
2467
+ `quick_check` did not run.
2468
+ """
2469
+ state = s.retained_artifacts or {"policy_status": "not-scanned"}
2470
+ if state.get("policy_status") == "unavailable":
2471
+ # ATTEMPTED and failed, which is what earns the WARN. An ABSENT field
2472
+ # is a state nothing populated — a shallow gather, or a DoctorState
2473
+ # assembled for one other leg — and it falls through to the
2474
+ # "not scanned" branch below instead, the same way `db.integrity`
2475
+ # reads an absent `quick_check`.
2476
+ reason = state.get("scan_error")
2477
+ return CheckResult(
2478
+ id="db.retained_artifacts", title="Retained evidence",
2479
+ severity="warn",
2480
+ summary=(
2481
+ f"retention scan unavailable ({reason})" if reason
2482
+ else "retention scan unavailable"
2483
+ ),
2484
+ remediation=(
2485
+ "Nothing is reporting the retained corpus while this persists. "
2486
+ "Run `cctally db prune` to scan it directly."
2487
+ ),
2488
+ details={"available": False, "scanned": False, "scanError": reason},
2489
+ )
2490
+
2491
+ scanned = state.get("policy_status") not in ("not-scanned", "malformed")
2492
+ retained = _int_or_none(state.get("retained_bytes"))
2493
+ reclaimable = _int_or_none(state.get("reclaimable_bytes"))
2494
+ protected = _int_or_none(state.get("protected_bytes"))
2495
+ unsatisfied = list(state.get("unsatisfied_rules") or [])
2496
+ stuck = [
2497
+ record for record in (state.get("stuck_records") or [])
2498
+ if record.get("stuck")
2499
+ ]
2500
+ details = {
2501
+ "policyStatus": state.get("policy_status"),
2502
+ "policyReason": state.get("policy_reason"),
2503
+ "scanned": scanned,
2504
+ "retainedBytes": retained,
2505
+ "reclaimableBytes": reclaimable,
2506
+ "protectedBytes": protected,
2507
+ "protectedRoots": _int_or_none(state.get("protected_roots")),
2508
+ "roots": _int_or_none(state.get("roots")),
2509
+ "freeDiskBytes": state.get("free_disk_bytes"),
2510
+ "partialScan": bool(state.get("partial_scan")),
2511
+ "unsatisfiedRules": unsatisfied,
2512
+ "floorRetainedRoots": _int_or_none(state.get("floor_retained_roots")),
2513
+ "floorRetainedBytes": _int_or_none(state.get("floor_retained_bytes")),
2514
+ "stuckRecords": stuck,
2515
+ }
2516
+
2517
+ if state.get("policy_status") == "malformed":
2518
+ return CheckResult(
2519
+ id="db.retained_artifacts", title="Retained evidence",
2520
+ severity="fail",
2521
+ summary=(
2522
+ "retention policy in config.json is malformed — automatic "
2523
+ "reclaim is off"
2524
+ ),
2525
+ remediation=(
2526
+ "Fix or remove the storage.artifact_retention block: "
2527
+ + str(state.get("policy_reason") or "see `cctally db prune`")
2528
+ ),
2529
+ details=details,
2530
+ )
2531
+ if unsatisfied:
2532
+ return CheckResult(
2533
+ id="db.retained_artifacts", title="Retained evidence",
2534
+ severity="fail",
2535
+ summary=(
2536
+ f"{_gib_text(retained)} retained; {_gib_text(protected)} "
2537
+ f"protected leaves {_bound_phrases(unsatisfied, state)} "
2538
+ "unsatisfied"
2539
+ ),
2540
+ remediation=(
2541
+ "Run `cctally db prune` to see which groups are protected and "
2542
+ "why; evidence cctally cannot classify is never deleted"
2543
+ ),
2544
+ details=details,
2545
+ )
2546
+ if stuck:
2547
+ record = stuck[0]
2548
+ members = ", ".join(record.get("memberIds") or []) or "an unnamed member"
2549
+ return CheckResult(
2550
+ id="db.retained_artifacts", title="Retained evidence",
2551
+ severity="warn",
2552
+ summary=(
2553
+ f"reclaim plan {record.get('planId')} has been stuck for "
2554
+ f"{_relative_age(record.get('ageSeconds')).replace(' ago', '')}"
2555
+ f" on {members}"
2556
+ ),
2557
+ remediation=(
2558
+ "No reclamation pass can decide this entry. Inspect the named "
2559
+ "member, then remove .reclaim-pending-"
2560
+ f"{record.get('planId')}.json from "
2561
+ "~/.local/share/cctally/ once nothing is pending on it"
2562
+ ),
2563
+ details=details,
2564
+ )
2565
+ if not scanned:
2566
+ # The `deep` gate (§7.5). The dashboard and TUI reach this gather every
2567
+ # rebuild, so the walk and the planner run only for the CLI. The two
2568
+ # conditions an operator must act on — a malformed policy and a stuck
2569
+ # reclaim record — are cheap and were decided above, so nothing
2570
+ # actionable is hidden by the skip.
2571
+ return CheckResult(
2572
+ id="db.retained_artifacts", title="Retained evidence",
2573
+ severity="ok",
2574
+ summary="not scanned (fast gather — run `cctally doctor`)",
2575
+ remediation=None, details=details,
2576
+ )
2577
+ if state.get("partial_scan"):
2578
+ return CheckResult(
2579
+ id="db.retained_artifacts", title="Retained evidence",
2580
+ severity="warn",
2581
+ summary=(
2582
+ f"{_gib_text(retained)} retained, from a partial scan — the "
2583
+ "walk stopped at its entry cap"
2584
+ ),
2585
+ remediation=(
2586
+ "Run `cctally db prune` to see the corpus; the figures here "
2587
+ "cover only part of it"
2588
+ ),
2589
+ details=details,
2590
+ )
2591
+ if reclaimable > 0:
2592
+ return CheckResult(
2593
+ id="db.retained_artifacts", title="Retained evidence",
2594
+ severity="warn",
2595
+ summary=(
2596
+ f"{_gib_text(retained)} retained, {_gib_text(reclaimable)} "
2597
+ f"reclaimable — over "
2598
+ f"{_bound_phrases(state.get('driving_rules') or (), state)}"
2599
+ ),
2600
+ remediation="Run `cctally db prune` to preview, `--yes` to apply.",
2601
+ details=details,
2602
+ )
2603
+ return CheckResult(
2604
+ id="db.retained_artifacts", title="Retained evidence", severity="ok",
2605
+ summary=f"{_gib_text(retained)} retained, within policy",
2606
+ remediation=None, details=details,
2607
+ )
2608
+
2609
+
2365
2610
  # ── DB journal redesign §9 — append-only journal legs ────────────────────
2366
2611
  # A monthly segment is MB-scale (§4.5), so a multi-MB unconsumed cursor gap
2367
2612
  # means no ingest cycle has run for a long stretch. An auto-heal incident within
@@ -2465,31 +2710,139 @@ def _check_journal_index_freshness(s: DoctorState) -> CheckResult:
2465
2710
  )
2466
2711
 
2467
2712
 
2713
+ #: A damage token of the literal `none` is not a shape (§3.4), so two of them
2714
+ #: are not a recurrence. Eleven of the 29 tokens on the maintainer's store
2715
+ #: carry this value, which is why the exclusion is load-bearing rather than
2716
+ #: defensive.
2717
+ _NON_SHAPE_TOKEN = "none"
2718
+
2719
+ #: Three detections inside the window, the same threshold
2720
+ #: `stats_heal_recurrence` already escalates on
2721
+ #: (`bin/_cctally_store.py:2275-2276`).
2722
+ _JOURNAL_HEAL_RECURRENCE_THRESHOLD = 3
2723
+
2724
+
2725
+ def _relative_age(age_s) -> str:
2726
+ """`45m ago` / `3h ago` / `2d ago`.
2727
+
2728
+ `age_s // 86400` rendered every sub-day incident as "0d ago", which is the
2729
+ exact wording an operator reads as "nothing happened today".
2730
+ """
2731
+ if age_s is None:
2732
+ return "age unknown"
2733
+ age_s = int(age_s)
2734
+ if age_s < 3600:
2735
+ return f"{max(age_s // 60, 0)}m ago"
2736
+ if age_s < 86400:
2737
+ return f"{age_s // 3600}h ago"
2738
+ return f"{age_s // 86400}d ago"
2739
+
2740
+
2468
2741
  def _check_journal_auto_heal(s: DoctorState) -> CheckResult:
2469
- """The most recent auto-heal incident (quarantine dir + forensics bundle).
2470
- INFO listing the latest; WARN when it fired within the last 7 days."""
2471
- incidents = s.journal_heal_incidents
2472
- if not incidents:
2742
+ """Auto-heal incidents and their recurrence (#496 S6 §7.2).
2743
+
2744
+ Identity rules, because the gather enumerates two different artifacts: an
2745
+ INCIDENT is a quarantine directory and nothing else, a DETECTION is a
2746
+ heal-ring entry keyed by `healId`, and a forensics bundle is linked
2747
+ evidence that is neither. Without the join rule a directory and the bundle
2748
+ written moments before it would count as two incidents.
2749
+
2750
+ `ok` when there is no incident and no detection; `warn` for a single
2751
+ historical incident or non-recurring detections; `fail` on at least three
2752
+ detections in seven days, or the same non-`none` damage shape in two
2753
+ distinct incidents within seven days.
2754
+ """
2755
+ raw_incidents = s.journal_heal_incidents or []
2756
+ incidents = [
2757
+ item for item in raw_incidents if item.get("kind") == "quarantine"
2758
+ ]
2759
+ detections = {
2760
+ item.get("heal_id"): item for item in (s.journal_heal_detections or [])
2761
+ }
2762
+ recent_detections = [
2763
+ item for item in detections.values()
2764
+ if item.get("age_s") is not None
2765
+ and item["age_s"] <= _JOURNAL_HEAL_RECENT_SECONDS
2766
+ ]
2767
+
2768
+ shape_counts: "dict[str, int]" = {}
2769
+ for incident in incidents:
2770
+ shape = incident.get("shape")
2771
+ if not isinstance(shape, str) or not shape or shape == _NON_SHAPE_TOKEN:
2772
+ continue
2773
+ age_s = incident.get("age_s")
2774
+ if age_s is None or age_s > _JOURNAL_HEAL_RECENT_SECONDS:
2775
+ continue
2776
+ shape_counts[shape] = shape_counts.get(shape, 0) + 1
2777
+ repeated = sorted(
2778
+ (shape for shape, count in shape_counts.items() if count >= 2),
2779
+ key=lambda shape: (-shape_counts[shape], shape),
2780
+ )
2781
+
2782
+ window_days = int(_JOURNAL_HEAL_RECENT_SECONDS // 86400)
2783
+ latest = incidents[0] if incidents else None
2784
+ details = {
2785
+ "incidents": len(incidents),
2786
+ "detections": len(detections),
2787
+ "recentDetections": len(recent_detections),
2788
+ "windowDays": window_days,
2789
+ "repeatedShapes": repeated,
2790
+ "latest": latest,
2791
+ }
2792
+
2793
+ if not incidents and not detections:
2473
2794
  return CheckResult(
2474
2795
  id="journal.auto_heal", title="Auto-heal", severity="ok",
2475
2796
  summary="no auto-heal incidents", remediation=None,
2476
- details={"incidents": 0},
2477
- )
2478
- latest = incidents[0]
2479
- age_s = latest.get("age_s")
2480
- name = latest.get("name", "?")
2481
- details = {"incidents": len(incidents), "latest": latest}
2482
- if age_s is not None and age_s <= _JOURNAL_HEAL_RECENT_SECONDS:
2483
- return CheckResult(
2484
- id="journal.auto_heal", title="Auto-heal", severity="warn",
2485
- summary=f"auto-heal fired recently ({name}, {age_s // 86400}d ago)",
2486
- remediation=("A DB corrupted and self-healed — inspect the forensics "
2487
- "bundle in ~/.local/share/cctally/logs/"),
2797
+ details=details,
2798
+ )
2799
+
2800
+ count = (
2801
+ f"{len(incidents)} incident{'' if len(incidents) == 1 else 's'}"
2802
+ if incidents else
2803
+ f"{len(detections)} detection{'' if len(detections) == 1 else 's'}"
2804
+ )
2805
+ when = _relative_age(latest.get("age_s")) if latest else None
2806
+ head = f"{count}, {when}" if when else count
2807
+
2808
+ if len(recent_detections) >= _JOURNAL_HEAL_RECURRENCE_THRESHOLD:
2809
+ return CheckResult(
2810
+ id="journal.auto_heal", title="Auto-heal", severity="fail",
2811
+ summary=(
2812
+ f"{len(recent_detections)} heals in {window_days}d — recurring"
2813
+ + (
2814
+ f"; shape {repeated[0]} seen {shape_counts[repeated[0]]}x"
2815
+ if repeated else ""
2816
+ )
2817
+ ),
2818
+ remediation=(
2819
+ "A DB has corrupted repeatedly — report the bundles in "
2820
+ "~/.local/share/cctally/logs/ and the events in "
2821
+ "logs/stats-heal-events.json"
2822
+ ),
2823
+ details=details,
2824
+ )
2825
+ if repeated:
2826
+ return CheckResult(
2827
+ id="journal.auto_heal", title="Auto-heal", severity="fail",
2828
+ summary=(
2829
+ f"{head}; the same damage shape {repeated[0]} appears in "
2830
+ f"{shape_counts[repeated[0]]} incidents within {window_days}d"
2831
+ ),
2832
+ remediation=(
2833
+ "The same damage is recurring — report the manifests in "
2834
+ "~/.local/share/cctally/quarantine/"
2835
+ ),
2488
2836
  details=details,
2489
2837
  )
2490
2838
  return CheckResult(
2491
- id="journal.auto_heal", title="Auto-heal", severity="ok",
2492
- summary=f"last incident {name}", remediation=None, details=details,
2839
+ id="journal.auto_heal", title="Auto-heal", severity="warn",
2840
+ summary=f"{head}; no recurrence in {window_days}d",
2841
+ remediation=(
2842
+ "A DB corrupted and self-healed — inspect the forensics bundle in "
2843
+ "~/.local/share/cctally/logs/"
2844
+ ),
2845
+ details=details,
2493
2846
  )
2494
2847
 
2495
2848
 
@@ -3029,6 +3382,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
3029
3382
  ("db.wal_size", "_check_db_wal_size"),
3030
3383
  ("db.reclaimable", "_check_db_reclaimable"),
3031
3384
  ("db.conversations_reclaimable", "_check_db_conversations_reclaimable"),
3385
+ ("db.retained_artifacts", "_check_db_retained_artifacts"),
3032
3386
  )),
3033
3387
  ("journal", "Journal", (
3034
3388
  ("journal.presence", "_check_journal_presence"),