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.
- package/CHANGELOG.md +48 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
package/bin/_lib_display_tz.py
CHANGED
|
@@ -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",
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -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}.
|
|
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
|
|
2185
|
-
"
|
|
2186
|
-
"
|
|
2187
|
-
"
|
|
2188
|
-
"
|
|
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
|
-
"""
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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=
|
|
2477
|
-
)
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
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="
|
|
2492
|
-
summary=f"
|
|
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"),
|