cctally 1.82.0 → 1.83.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 +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
package/bin/_lib_doctor.py
CHANGED
|
@@ -250,6 +250,10 @@ class DoctorState:
|
|
|
250
250
|
# * journal_appendable — the dir is writable (os.access W_OK); None when
|
|
251
251
|
# absent or the probe errored.
|
|
252
252
|
# * journal_segment_count — number of segments (bootstrap + monthly).
|
|
253
|
+
# * journal_has_bytes — at least one canonical segment retains bytes.
|
|
254
|
+
# A merely-created journal/ directory (or wholly empty segment set)
|
|
255
|
+
# cannot rebuild a disposable stats index; an empty newest segment does
|
|
256
|
+
# not hide replayable bytes in older immutable segments.
|
|
253
257
|
# * journal_malformed_count / journal_torn_tail_count — mid-file malformed
|
|
254
258
|
# lines (external damage → WARN) and torn final lines (a known crash
|
|
255
259
|
# artifact healed by the next append → INFO). None = not scanned (the
|
|
@@ -266,12 +270,37 @@ class DoctorState:
|
|
|
266
270
|
journal_present: bool = False
|
|
267
271
|
journal_appendable: Optional[bool] = None
|
|
268
272
|
journal_segment_count: int = 0
|
|
273
|
+
journal_has_bytes: bool = False
|
|
269
274
|
journal_malformed_count: Optional[int] = None
|
|
270
275
|
journal_torn_tail_count: Optional[int] = None
|
|
271
276
|
journal_cursor_lag_bytes: Optional[int] = None
|
|
272
277
|
journal_hw_segment: Optional[str] = None
|
|
273
278
|
journal_cursor_segment: Optional[str] = None
|
|
274
279
|
journal_heal_incidents: Optional[list] = None
|
|
280
|
+
# * journal_writer_guard (#386) — the stats sole-writer guard's log
|
|
281
|
+
# (logs/stats-writer-guard.log). `None` = the log is absent (the normal
|
|
282
|
+
# state, and NOT an error); otherwise
|
|
283
|
+
# {entries, newest_age_s, path, sample} where `sample` is the most
|
|
284
|
+
# recent line. On an installed build the authorizer LOGS instead of
|
|
285
|
+
# raising, so this leg is the only surface a field violation reaches.
|
|
286
|
+
journal_writer_guard: Optional[dict] = None
|
|
287
|
+
# * journal_conflicts (#374) — the quarantined same-revision groups the
|
|
288
|
+
# SHARED selector reports, each an `EventConflict.to_dict()`. `None` =
|
|
289
|
+
# not scanned (shallow gather, no journal, or the selector raised);
|
|
290
|
+
# `[]` = selection completed cleanly. Deep-gated, like the malformed
|
|
291
|
+
# scan, because it decodes every line and normalizes account stamps
|
|
292
|
+
# exactly as `rebuild_stats_index` does — raw `(id, rev)` grouping would
|
|
293
|
+
# report superseded revisions and false account conflicts.
|
|
294
|
+
# * journal_protocol_violations (#402) — completed selector results for
|
|
295
|
+
# whole tainted correction batches. `[]` means a clean deep scan;
|
|
296
|
+
# `None` means not scanned or the selector failed.
|
|
297
|
+
# * journal_protocol_error (#374) — an out-of-scope fatal
|
|
298
|
+
# `JournalProtocolError` raised by that same selector. Mutually
|
|
299
|
+
# exclusive with conflicts/violations because selection did not finish.
|
|
300
|
+
journal_conflicts: Optional[list] = None
|
|
301
|
+
journal_protocol_violations: Optional[list] = None
|
|
302
|
+
journal_protocol_acknowledged: Optional[list] = None
|
|
303
|
+
journal_protocol_error: Optional[str] = None
|
|
275
304
|
# Multi-account attribution (#341). A dict (None when unavailable) with:
|
|
276
305
|
# * claude_identity_status — "identified" | "stably_absent" | "torn"
|
|
277
306
|
# * claude_email — the active Claude email (or None)
|
|
@@ -286,6 +315,10 @@ class DoctorState:
|
|
|
286
315
|
# cache.db.repairing. {exists, live, reason}; None only when gather itself
|
|
287
316
|
# could not inspect the marker. Doctor never reclaims or deletes it.
|
|
288
317
|
cache_repair_marker: Optional[dict] = None
|
|
318
|
+
# #411: read-only, fail-soft classification of whether APP_DIR is inside a
|
|
319
|
+
# known file-level backup/sync root. Only provider/status are retained so
|
|
320
|
+
# reports never expose the machine-specific data path.
|
|
321
|
+
backup_sync_state: Optional[dict] = None
|
|
289
322
|
|
|
290
323
|
|
|
291
324
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -675,6 +708,19 @@ def _db_file_check(label_id: str, label_title: str, status: Optional[dict],
|
|
|
675
708
|
remediation="Re-run; see stderr",
|
|
676
709
|
details={"reason": "gather returned None"},
|
|
677
710
|
)
|
|
711
|
+
interrupted = status.get("_interrupted_rebuild")
|
|
712
|
+
if interrupted and interrupted.get("live") is True:
|
|
713
|
+
return CheckResult(
|
|
714
|
+
id=label_id,
|
|
715
|
+
title=label_title,
|
|
716
|
+
severity="warn",
|
|
717
|
+
summary="stats.db rebuild is in progress",
|
|
718
|
+
remediation="Wait for the active rebuild to finish, then re-run Doctor.",
|
|
719
|
+
details={
|
|
720
|
+
"path": status["path"],
|
|
721
|
+
"interrupted_rebuild": interrupted,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
678
724
|
if status.get("_open_error"):
|
|
679
725
|
return CheckResult(
|
|
680
726
|
id=label_id, title=label_title,
|
|
@@ -682,6 +728,22 @@ def _db_file_check(label_id: str, label_title: str, status: Optional[dict],
|
|
|
682
728
|
remediation=rebuild_hint,
|
|
683
729
|
details={"exception": status["_open_error"], "path": status["path"]},
|
|
684
730
|
)
|
|
731
|
+
if interrupted and interrupted.get("live") is False:
|
|
732
|
+
return CheckResult(
|
|
733
|
+
id=label_id,
|
|
734
|
+
title=label_title,
|
|
735
|
+
severity="warn",
|
|
736
|
+
summary="interrupted rebuild detected; next guarded open will recover",
|
|
737
|
+
remediation=(
|
|
738
|
+
"Run `cctally report` or restart `cctally dashboard` to retry "
|
|
739
|
+
"automatic recovery. If recovery fails, run "
|
|
740
|
+
"`cctally db rebuild --db stats`."
|
|
741
|
+
),
|
|
742
|
+
details={
|
|
743
|
+
"path": status["path"],
|
|
744
|
+
"interrupted_rebuild": interrupted,
|
|
745
|
+
},
|
|
746
|
+
)
|
|
685
747
|
if status.get("_file_exists") is False:
|
|
686
748
|
return CheckResult(
|
|
687
749
|
id=label_id, title=label_title,
|
|
@@ -804,9 +866,10 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
|
804
866
|
* ``uv == epoch`` (a cut-over install) → HEALTHY (steady state)
|
|
805
867
|
* ``uv <= legacy_head`` (pre-cutover, ≤13) → HEALTHY (cuts over on open)
|
|
806
868
|
* ``uv > legacy_head`` AND ``!= epoch`` → §7.1 index MISMATCH: WARN.
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
869
|
+
With retained journal bytes it self-heals by journal REBUILD on the next
|
|
870
|
+
open; without them it fails closed and asks the operator to restore the
|
|
871
|
+
durable source. The remediation never points at the retired
|
|
872
|
+
`db recover --db stats`.
|
|
810
873
|
|
|
811
874
|
cache.db is unchanged (issue #145): a ``user_version`` past the cache
|
|
812
875
|
registry head auto-heals on the next open → WARN. doctor reads raw
|
|
@@ -836,11 +899,27 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
|
836
899
|
|
|
837
900
|
stats = _eval_stats(s.stats_db_status)
|
|
838
901
|
cache = _eval_cache(s.cache_db_status)
|
|
839
|
-
details = {"stats.db": stats, "cache.db": cache}
|
|
840
902
|
stats_mismatch = bool(stats and stats["mismatch"])
|
|
841
903
|
cache_ahead = bool(cache and cache["ahead"])
|
|
904
|
+
if stats_mismatch:
|
|
905
|
+
stats["journal_present"] = bool(s.journal_present)
|
|
906
|
+
stats["journal_has_bytes"] = bool(s.journal_has_bytes)
|
|
907
|
+
details = {"stats.db": stats, "cache.db": cache}
|
|
842
908
|
|
|
843
909
|
if stats_mismatch:
|
|
910
|
+
if not s.journal_has_bytes:
|
|
911
|
+
return CheckResult(
|
|
912
|
+
id="db.version_ahead", title="Version ahead", severity="warn",
|
|
913
|
+
summary=(
|
|
914
|
+
f"stats.db index mismatch (v{stats['user_version']} ≠ epoch "
|
|
915
|
+
f"v{stats['epoch']}) — no journal data available"
|
|
916
|
+
),
|
|
917
|
+
remediation=(
|
|
918
|
+
"Restore the journal/ directory from backup, then run "
|
|
919
|
+
"`cctally db rebuild --db stats`"
|
|
920
|
+
),
|
|
921
|
+
details=details,
|
|
922
|
+
)
|
|
844
923
|
return CheckResult(
|
|
845
924
|
id="db.version_ahead", title="Version ahead", severity="warn",
|
|
846
925
|
summary=(f"stats.db index mismatch (v{stats['user_version']} ≠ epoch "
|
|
@@ -1541,6 +1620,42 @@ def _check_safety_dashboard_bind(s: DoctorState) -> CheckResult:
|
|
|
1541
1620
|
)
|
|
1542
1621
|
|
|
1543
1622
|
|
|
1623
|
+
def _check_safety_backup_sync(s: DoctorState) -> CheckResult:
|
|
1624
|
+
state = (
|
|
1625
|
+
s.backup_sync_state
|
|
1626
|
+
if isinstance(s.backup_sync_state, dict)
|
|
1627
|
+
else {"status": "unavailable", "provider": None}
|
|
1628
|
+
)
|
|
1629
|
+
status = state.get("status")
|
|
1630
|
+
provider = state.get("provider")
|
|
1631
|
+
details = {"status": status, "provider": provider}
|
|
1632
|
+
if status == "included" and provider:
|
|
1633
|
+
return CheckResult(
|
|
1634
|
+
id="safety.backup_sync", title="Backup/sync",
|
|
1635
|
+
severity="warn",
|
|
1636
|
+
summary=f"cctally data is inside {provider}",
|
|
1637
|
+
remediation=(
|
|
1638
|
+
"Exclude the cctally data directory from file-level backup/sync "
|
|
1639
|
+
"and use `cctally db backup --db stats` or `--db cache` for "
|
|
1640
|
+
"consistent SQLite snapshots"
|
|
1641
|
+
),
|
|
1642
|
+
details=details,
|
|
1643
|
+
)
|
|
1644
|
+
summaries = {
|
|
1645
|
+
"absent": "no configured file-level backup/sync detected",
|
|
1646
|
+
"excluded": f"cctally data excluded from {provider or 'backup/sync'}",
|
|
1647
|
+
"unsupported": "backup/sync probe unsupported on this platform",
|
|
1648
|
+
"unavailable": "backup/sync probe unavailable",
|
|
1649
|
+
}
|
|
1650
|
+
return CheckResult(
|
|
1651
|
+
id="safety.backup_sync", title="Backup/sync",
|
|
1652
|
+
severity="ok",
|
|
1653
|
+
summary=summaries.get(status, "backup/sync state unavailable"),
|
|
1654
|
+
remediation=None,
|
|
1655
|
+
details=details,
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
|
|
1544
1659
|
def _check_safety_config_json_valid(s: DoctorState) -> CheckResult:
|
|
1545
1660
|
if s.config_json_error is None:
|
|
1546
1661
|
return CheckResult(
|
|
@@ -2082,6 +2197,225 @@ def _check_journal_auto_heal(s: DoctorState) -> CheckResult:
|
|
|
2082
2197
|
)
|
|
2083
2198
|
|
|
2084
2199
|
|
|
2200
|
+
#: A guard entry inside this window is actionable; older ones are history.
|
|
2201
|
+
_WRITER_GUARD_RECENT_SECONDS = 7 * 24 * 3600
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
def _check_journal_writer_guard(s: DoctorState) -> CheckResult:
|
|
2205
|
+
"""Unsanctioned stats.db writes recorded by the #386 authorizer (spec §6.4).
|
|
2206
|
+
|
|
2207
|
+
On a dev checkout the guard RAISES, so a violation is loud at the call site.
|
|
2208
|
+
On an installed build it appends one throttled line to
|
|
2209
|
+
`logs/stats-writer-guard.log` and lets the write through — deliberately, so
|
|
2210
|
+
the guard can never break a user's command. This leg is therefore the ONLY
|
|
2211
|
+
surface such a violation reaches in the field.
|
|
2212
|
+
|
|
2213
|
+
INFO when the log is absent or empty (the normal state — an absent log is
|
|
2214
|
+
not a failure), WARN when it holds entries newer than 7 days.
|
|
2215
|
+
"""
|
|
2216
|
+
guard = s.journal_writer_guard
|
|
2217
|
+
if not guard or not guard.get("entries"):
|
|
2218
|
+
return CheckResult(
|
|
2219
|
+
id="journal.writer_guard", title="Stats writer guard", severity="ok",
|
|
2220
|
+
summary="no unsanctioned stats.db writes recorded", remediation=None,
|
|
2221
|
+
details={"entries": 0},
|
|
2222
|
+
)
|
|
2223
|
+
entries = int(guard.get("entries") or 0)
|
|
2224
|
+
age_s = guard.get("newest_age_s")
|
|
2225
|
+
path = guard.get("path")
|
|
2226
|
+
details = {
|
|
2227
|
+
"entries": entries,
|
|
2228
|
+
"newestAgeS": age_s,
|
|
2229
|
+
"path": path,
|
|
2230
|
+
"sample": guard.get("sample"),
|
|
2231
|
+
}
|
|
2232
|
+
if age_s is not None and age_s <= _WRITER_GUARD_RECENT_SECONDS:
|
|
2233
|
+
return CheckResult(
|
|
2234
|
+
id="journal.writer_guard", title="Stats writer guard", severity="warn",
|
|
2235
|
+
summary=(
|
|
2236
|
+
f"{entries} unsanctioned stats.db write(s) recorded, newest "
|
|
2237
|
+
f"{age_s // 3600}h ago"
|
|
2238
|
+
),
|
|
2239
|
+
remediation=(
|
|
2240
|
+
"A code path wrote stats.db outside the ingest cycle and outside "
|
|
2241
|
+
f"the maintenance lock (#386). Inspect {path} and report it — "
|
|
2242
|
+
"the write was allowed through, so no data was lost."
|
|
2243
|
+
),
|
|
2244
|
+
details=details,
|
|
2245
|
+
)
|
|
2246
|
+
return CheckResult(
|
|
2247
|
+
id="journal.writer_guard", title="Stats writer guard", severity="ok",
|
|
2248
|
+
summary=f"{entries} unsanctioned write(s), none recent", remediation=None,
|
|
2249
|
+
details=details,
|
|
2250
|
+
)
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
# #374: the families `db rederive` owns. A quarantined group outside this set
|
|
2254
|
+
# (a retained `qaa:` state stream, or an unknown prefix from a newer binary) is
|
|
2255
|
+
# still reported, but `db rederive` is the WRONG remedy for it and the leg must
|
|
2256
|
+
# not promise one it cannot deliver.
|
|
2257
|
+
_REDERIVABLE_CONFLICT_PREFIXES = (
|
|
2258
|
+
"sa:", "wcs:", "wce:", "wr:", "fhc:", "fhbc:", "pm:", "fhm:",
|
|
2259
|
+
"bm:", "pjm:", "pbm:",
|
|
2260
|
+
)
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
def _check_journal_conflicts(s: DoctorState) -> CheckResult:
|
|
2264
|
+
"""Divergent same-revision EVENT groups quarantined behind a provisional
|
|
2265
|
+
winner (#374). WARN — never FAIL: the index is complete and usable, we
|
|
2266
|
+
simply refuse to assert that a guessed winner is authoritative.
|
|
2267
|
+
|
|
2268
|
+
Emitted only when selection COMPLETES. When the shared selector raised a
|
|
2269
|
+
structural violation there is no conflicts result to report, so this leg
|
|
2270
|
+
reports itself unavailable and `journal.protocol` carries the FAIL."""
|
|
2271
|
+
if s.journal_protocol_error:
|
|
2272
|
+
return CheckResult(
|
|
2273
|
+
id="journal.conflicts", title="Journal conflicts", severity="ok",
|
|
2274
|
+
summary="unavailable (structural protocol violation)",
|
|
2275
|
+
remediation=None,
|
|
2276
|
+
details={"scanned": True, "available": False, "conflicts": None},
|
|
2277
|
+
)
|
|
2278
|
+
if s.journal_conflicts is None:
|
|
2279
|
+
return CheckResult(
|
|
2280
|
+
id="journal.conflicts", title="Journal conflicts", severity="ok",
|
|
2281
|
+
summary="not scanned", remediation=None,
|
|
2282
|
+
details={"scanned": False, "available": None, "conflicts": None},
|
|
2283
|
+
)
|
|
2284
|
+
conflicts = list(s.journal_conflicts)
|
|
2285
|
+
if not conflicts:
|
|
2286
|
+
return CheckResult(
|
|
2287
|
+
id="journal.conflicts", title="Journal conflicts", severity="ok",
|
|
2288
|
+
summary="no quarantined events", remediation=None,
|
|
2289
|
+
details={"scanned": True, "available": True, "conflicts": []},
|
|
2290
|
+
)
|
|
2291
|
+
details = {"scanned": True, "available": True, "conflicts": conflicts}
|
|
2292
|
+
rederivable = [
|
|
2293
|
+
c for c in conflicts
|
|
2294
|
+
if str(c.get("eventId") or "").startswith(_REDERIVABLE_CONFLICT_PREFIXES)
|
|
2295
|
+
]
|
|
2296
|
+
if rederivable:
|
|
2297
|
+
remediation = (
|
|
2298
|
+
"Run `cctally db rederive --family claude-usage` to supersede the "
|
|
2299
|
+
"quarantined group(s) at the next revision; until then the index "
|
|
2300
|
+
"uses the first-written variant"
|
|
2301
|
+
)
|
|
2302
|
+
else:
|
|
2303
|
+
remediation = (
|
|
2304
|
+
"These groups are outside the claude-usage rederive family — the "
|
|
2305
|
+
"index uses the first-written variant; report the event ids if the "
|
|
2306
|
+
"affected data looks wrong"
|
|
2307
|
+
)
|
|
2308
|
+
return CheckResult(
|
|
2309
|
+
id="journal.conflicts", title="Journal conflicts", severity="warn",
|
|
2310
|
+
summary=(f"{len(conflicts)} quarantined same-revision group(s) "
|
|
2311
|
+
"behind a provisional winner"),
|
|
2312
|
+
remediation=remediation, details=details,
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def _check_journal_protocol(s: DoctorState) -> CheckResult:
|
|
2317
|
+
"""Report selector failure or whole-batch tainting without false health."""
|
|
2318
|
+
if (
|
|
2319
|
+
not s.journal_protocol_error
|
|
2320
|
+
and s.journal_protocol_violations is None
|
|
2321
|
+
and s.journal_protocol_acknowledged is None
|
|
2322
|
+
):
|
|
2323
|
+
return CheckResult(
|
|
2324
|
+
id="journal.protocol", title="Journal protocol", severity="ok",
|
|
2325
|
+
summary="not scanned", remediation=None,
|
|
2326
|
+
details={
|
|
2327
|
+
"scanned": False,
|
|
2328
|
+
"error": None,
|
|
2329
|
+
"violations": None,
|
|
2330
|
+
},
|
|
2331
|
+
)
|
|
2332
|
+
violations = list(s.journal_protocol_violations or [])
|
|
2333
|
+
acknowledged = list(s.journal_protocol_acknowledged or [])
|
|
2334
|
+
if not s.journal_protocol_error and not violations and not acknowledged:
|
|
2335
|
+
return CheckResult(
|
|
2336
|
+
id="journal.protocol", title="Journal protocol", severity="ok",
|
|
2337
|
+
summary="no protocol violations", remediation=None,
|
|
2338
|
+
details={
|
|
2339
|
+
"scanned": True,
|
|
2340
|
+
"error": None,
|
|
2341
|
+
"violations": [],
|
|
2342
|
+
},
|
|
2343
|
+
)
|
|
2344
|
+
if violations:
|
|
2345
|
+
batch_kinds = [
|
|
2346
|
+
f"{item.get('batchId')}: {item.get('kind')}"
|
|
2347
|
+
for item in violations[:10]
|
|
2348
|
+
]
|
|
2349
|
+
selected = violations[:10]
|
|
2350
|
+
apply_command = "cctally db journal-repair " + " ".join(
|
|
2351
|
+
f"--violation {item.get('fingerprint')}"
|
|
2352
|
+
for item in selected
|
|
2353
|
+
) + " --yes"
|
|
2354
|
+
return CheckResult(
|
|
2355
|
+
id="journal.protocol", title="Journal protocol", severity="fail",
|
|
2356
|
+
summary=(
|
|
2357
|
+
f"{len(violations)} structural violation(s); index rebuilt "
|
|
2358
|
+
"with tainted correction batches omitted"
|
|
2359
|
+
),
|
|
2360
|
+
remediation=(
|
|
2361
|
+
"The index is usable, but the named correction batches were "
|
|
2362
|
+
"omitted. Preview with `cctally db journal-repair`, then apply "
|
|
2363
|
+
f"the exact current selection with `{apply_command}`. Do not "
|
|
2364
|
+
"edit journal segments by hand"
|
|
2365
|
+
),
|
|
2366
|
+
details={
|
|
2367
|
+
"scanned": True,
|
|
2368
|
+
"error": None,
|
|
2369
|
+
"violations": violations,
|
|
2370
|
+
"sample": batch_kinds,
|
|
2371
|
+
"previewCommand": "cctally db journal-repair",
|
|
2372
|
+
"applyCommand": apply_command,
|
|
2373
|
+
**(
|
|
2374
|
+
{"acknowledgedViolations": acknowledged}
|
|
2375
|
+
if acknowledged else {}
|
|
2376
|
+
),
|
|
2377
|
+
},
|
|
2378
|
+
)
|
|
2379
|
+
if acknowledged:
|
|
2380
|
+
return CheckResult(
|
|
2381
|
+
id="journal.protocol", title="Journal protocol", severity="warn",
|
|
2382
|
+
summary=(
|
|
2383
|
+
f"{len(acknowledged)} acknowledged structural violation(s); "
|
|
2384
|
+
"tainted correction batches remain omitted"
|
|
2385
|
+
),
|
|
2386
|
+
remediation=(
|
|
2387
|
+
"The operator audit is durable and the index is usable. The "
|
|
2388
|
+
"named correction batches remain omitted; inspect the audit "
|
|
2389
|
+
"details before relying on the affected history"
|
|
2390
|
+
),
|
|
2391
|
+
details={
|
|
2392
|
+
"scanned": True,
|
|
2393
|
+
"error": None,
|
|
2394
|
+
"violations": [],
|
|
2395
|
+
"acknowledgedViolations": acknowledged,
|
|
2396
|
+
"sample": [
|
|
2397
|
+
f"{item.get('batchId')}: {item.get('kind')}"
|
|
2398
|
+
for item in acknowledged[:10]
|
|
2399
|
+
],
|
|
2400
|
+
"previewCommand": "cctally db journal-repair",
|
|
2401
|
+
"applyCommand": None,
|
|
2402
|
+
},
|
|
2403
|
+
)
|
|
2404
|
+
return CheckResult(
|
|
2405
|
+
id="journal.protocol", title="Journal protocol", severity="fail",
|
|
2406
|
+
summary="journal selector failed before rebuild",
|
|
2407
|
+
remediation=(
|
|
2408
|
+
"A journal record is invalid outside the recoverable structural "
|
|
2409
|
+
"batch classes. Capture the journal segments and open an issue"
|
|
2410
|
+
),
|
|
2411
|
+
details={
|
|
2412
|
+
"scanned": True,
|
|
2413
|
+
"error": str(s.journal_protocol_error),
|
|
2414
|
+
"violations": None,
|
|
2415
|
+
},
|
|
2416
|
+
)
|
|
2417
|
+
|
|
2418
|
+
|
|
2085
2419
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
2086
2420
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
2087
2421
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -2246,6 +2580,9 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
2246
2580
|
("journal.integrity", "_check_journal_integrity"),
|
|
2247
2581
|
("journal.index_freshness", "_check_journal_index_freshness"),
|
|
2248
2582
|
("journal.auto_heal", "_check_journal_auto_heal"),
|
|
2583
|
+
("journal.writer_guard", "_check_journal_writer_guard"),
|
|
2584
|
+
("journal.conflicts", "_check_journal_conflicts"),
|
|
2585
|
+
("journal.protocol", "_check_journal_protocol"),
|
|
2249
2586
|
)),
|
|
2250
2587
|
("data", "Data", (
|
|
2251
2588
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
@@ -2271,6 +2608,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
2271
2608
|
)),
|
|
2272
2609
|
("safety", "Safety", (
|
|
2273
2610
|
("safety.dashboard_bind", "_check_safety_dashboard_bind"),
|
|
2611
|
+
("safety.backup_sync", "_check_safety_backup_sync"),
|
|
2274
2612
|
("safety.config_json_valid", "_check_safety_config_json_valid"),
|
|
2275
2613
|
("safety.update_state", "_check_safety_update_state"),
|
|
2276
2614
|
("safety.update_suppress", "_check_safety_update_suppress"),
|