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
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Preview-first audited repair for structural correction-batch violations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import signal
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import _cctally_core
|
|
15
|
+
import _cctally_journal as _journal
|
|
16
|
+
import _lib_journal
|
|
17
|
+
from _lib_json_envelope import stamp_schema_version
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_only_high_water() -> "tuple[str, int] | None":
|
|
21
|
+
"""Capture the append-only prefix without creating a lock or sidecar."""
|
|
22
|
+
segments = _journal.list_segments()
|
|
23
|
+
if not segments:
|
|
24
|
+
return None
|
|
25
|
+
latest = segments[-1]
|
|
26
|
+
return latest, os.path.getsize(_cctally_core.JOURNAL_DIR / latest)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_prefix(high_water):
|
|
30
|
+
if high_water is None:
|
|
31
|
+
return [], ()
|
|
32
|
+
records = []
|
|
33
|
+
evidence = []
|
|
34
|
+
malformed = 0
|
|
35
|
+
prior_high_water = None
|
|
36
|
+
for segment, offset, raw in _journal._read_range(None, high_water):
|
|
37
|
+
record = _lib_journal.decode_line(raw)
|
|
38
|
+
if record is None:
|
|
39
|
+
malformed += 1
|
|
40
|
+
prior_high_water = (segment, offset + len(raw) + 1)
|
|
41
|
+
continue
|
|
42
|
+
_journal._capture_protocol_prefix_evidence(
|
|
43
|
+
record,
|
|
44
|
+
prior_high_water,
|
|
45
|
+
evidence,
|
|
46
|
+
)
|
|
47
|
+
records.append(record)
|
|
48
|
+
prior_high_water = (segment, offset + len(raw) + 1)
|
|
49
|
+
if malformed:
|
|
50
|
+
raise _lib_journal.JournalProtocolError(
|
|
51
|
+
f"journal prefix contains {malformed} malformed line(s)"
|
|
52
|
+
)
|
|
53
|
+
cutover_claude = _journal.resolve_cutover_claude_account()
|
|
54
|
+
for record in records:
|
|
55
|
+
_journal._normalize_legacy_account_stamp(record, cutover_claude)
|
|
56
|
+
return records, tuple(evidence)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _prefix_hash(high_water) -> "str | None":
|
|
60
|
+
return _journal.journal_prefix_hash(high_water)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _high_water_dict(high_water):
|
|
64
|
+
if high_water is None:
|
|
65
|
+
return None
|
|
66
|
+
return {"segment": high_water[0], "offset": high_water[1]}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _selection_snapshot():
|
|
70
|
+
high_water = _read_only_high_water()
|
|
71
|
+
records, evidence = _read_prefix(high_water)
|
|
72
|
+
selection = _lib_journal.resolve_effective_events(
|
|
73
|
+
records,
|
|
74
|
+
protocol_prefix_evidence=evidence,
|
|
75
|
+
)
|
|
76
|
+
return high_water, _prefix_hash(high_water), selection
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _preview_payload(requested=()):
|
|
80
|
+
high_water, prefix_hash, selection = _selection_snapshot()
|
|
81
|
+
unacknowledged = {
|
|
82
|
+
violation.fingerprint: violation
|
|
83
|
+
for violation in selection.protocol_violations
|
|
84
|
+
}
|
|
85
|
+
acknowledged = {
|
|
86
|
+
violation.fingerprint: violation
|
|
87
|
+
for violation in selection.acknowledged_protocol_violations
|
|
88
|
+
}
|
|
89
|
+
requested = list(requested)
|
|
90
|
+
errors = []
|
|
91
|
+
if len(set(requested)) != len(requested):
|
|
92
|
+
errors.append("--violation fingerprints must not be repeated")
|
|
93
|
+
unknown = sorted(set(requested) - set(unacknowledged) - set(acknowledged))
|
|
94
|
+
if unknown:
|
|
95
|
+
errors.append("unknown violation fingerprint(s): " + ", ".join(unknown))
|
|
96
|
+
selected = []
|
|
97
|
+
for fingerprint in requested:
|
|
98
|
+
if fingerprint in unacknowledged:
|
|
99
|
+
selected.append(unacknowledged[fingerprint].to_dict())
|
|
100
|
+
elif fingerprint in acknowledged:
|
|
101
|
+
selected.append(acknowledged[fingerprint].violation.to_dict())
|
|
102
|
+
body = {
|
|
103
|
+
"status": "preview",
|
|
104
|
+
"journalHighWater": _high_water_dict(high_water),
|
|
105
|
+
"journalPrefixHash": prefix_hash,
|
|
106
|
+
"unacknowledgedViolations": [
|
|
107
|
+
violation.to_dict() for violation in selection.protocol_violations
|
|
108
|
+
],
|
|
109
|
+
"acknowledgedViolations": [
|
|
110
|
+
violation.to_dict()
|
|
111
|
+
for violation in selection.acknowledged_protocol_violations
|
|
112
|
+
],
|
|
113
|
+
"selectedViolations": selected,
|
|
114
|
+
"auditId": None,
|
|
115
|
+
"rebuild": None,
|
|
116
|
+
"errors": errors,
|
|
117
|
+
}
|
|
118
|
+
return stamp_schema_version(body, version=1), selection
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rebuild_dict(result):
|
|
122
|
+
if result is None:
|
|
123
|
+
return None
|
|
124
|
+
return {
|
|
125
|
+
"segmentsRead": result.segments_read,
|
|
126
|
+
"linesFolded": result.lines_folded,
|
|
127
|
+
"malformed": result.malformed,
|
|
128
|
+
"durationSeconds": round(result.duration_s, 3),
|
|
129
|
+
"rowsByTable": result.rows_by_table,
|
|
130
|
+
"journalConflicts": [
|
|
131
|
+
conflict.to_dict() for conflict in result.conflicts
|
|
132
|
+
],
|
|
133
|
+
"journalProtocolViolations": [
|
|
134
|
+
violation.to_dict() for violation in result.protocol_violations
|
|
135
|
+
],
|
|
136
|
+
"journalAcknowledgedProtocolViolations": [
|
|
137
|
+
violation.to_dict()
|
|
138
|
+
for violation in result.acknowledged_protocol_violations
|
|
139
|
+
],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@contextlib.contextmanager
|
|
144
|
+
def _repair_locks(timeout: float = 5.0):
|
|
145
|
+
"""Take the repository's maintenance -> ingest lock prefix."""
|
|
146
|
+
from _lib_cache_writer_lock import (
|
|
147
|
+
acquire_ordered_flocks,
|
|
148
|
+
release_cache_writer_flocks,
|
|
149
|
+
)
|
|
150
|
+
import _cctally_store
|
|
151
|
+
|
|
152
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
held = acquire_ordered_flocks(
|
|
154
|
+
[
|
|
155
|
+
(_cctally_core.STATS_LOCK_MAINTENANCE_PATH, fcntl.LOCK_EX),
|
|
156
|
+
(_cctally_core.JOURNAL_INGEST_LOCK_PATH, fcntl.LOCK_EX),
|
|
157
|
+
],
|
|
158
|
+
timeout=timeout,
|
|
159
|
+
)
|
|
160
|
+
if held is None:
|
|
161
|
+
raise _journal.JournalError(
|
|
162
|
+
"another database sync or maintenance operation holds the "
|
|
163
|
+
"journal-repair lock set; retry shortly"
|
|
164
|
+
)
|
|
165
|
+
_cctally_core.note_stats_maintenance_acquired()
|
|
166
|
+
try:
|
|
167
|
+
with _cctally_store.stats_write_scope(
|
|
168
|
+
"journal-repair", ingest_lock=True
|
|
169
|
+
):
|
|
170
|
+
yield
|
|
171
|
+
finally:
|
|
172
|
+
_cctally_core.note_stats_maintenance_released()
|
|
173
|
+
release_cache_writer_flocks(held)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _iso_now() -> str:
|
|
177
|
+
return (
|
|
178
|
+
dt.datetime.now(dt.timezone.utc)
|
|
179
|
+
.replace(microsecond=0)
|
|
180
|
+
.isoformat()
|
|
181
|
+
.replace("+00:00", "Z")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _call_crash_hook(stage: str) -> None:
|
|
186
|
+
if (
|
|
187
|
+
os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_MODE") == "1"
|
|
188
|
+
and os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_CRASH_STAGE") == stage
|
|
189
|
+
):
|
|
190
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _call_pause_hook(stage: str) -> None:
|
|
194
|
+
"""Private process-control seam for the #402 locked-revalidation test."""
|
|
195
|
+
if (
|
|
196
|
+
os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_MODE") != "1"
|
|
197
|
+
or os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_PAUSE_STAGE") != stage
|
|
198
|
+
):
|
|
199
|
+
return
|
|
200
|
+
marker = os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_PAUSE_MARKER")
|
|
201
|
+
if not marker:
|
|
202
|
+
return
|
|
203
|
+
pathlib.Path(marker).write_text(f"{os.getpid()}\n")
|
|
204
|
+
os.kill(os.getpid(), signal.SIGSTOP)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _call_rebuild_error_hook() -> None:
|
|
208
|
+
if (
|
|
209
|
+
os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_MODE") == "1"
|
|
210
|
+
and os.environ.get("CCTALLY_JOURNAL_REPAIR_TEST_REBUILD_ERROR")
|
|
211
|
+
== "sqlite"
|
|
212
|
+
):
|
|
213
|
+
raise sqlite3.DatabaseError("injected scratch sqlite failure")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _repair_failure_guidance(exc: Exception) -> str:
|
|
217
|
+
detail = str(exc)
|
|
218
|
+
lower = detail.lower()
|
|
219
|
+
if (
|
|
220
|
+
"open handle" in lower
|
|
221
|
+
or "family is still open" in lower
|
|
222
|
+
or "open in process" in lower
|
|
223
|
+
):
|
|
224
|
+
return (
|
|
225
|
+
f"{detail}; stop the dashboard or other process holding stats.db "
|
|
226
|
+
"open, then rerun the identical db journal-repair command"
|
|
227
|
+
)
|
|
228
|
+
return detail
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _post_audit_failure(requested, audit_ids, exc):
|
|
232
|
+
"""Report durable acknowledgement truth when index publication declined."""
|
|
233
|
+
payload, _selection = _preview_payload(requested)
|
|
234
|
+
payload["status"] = "failed"
|
|
235
|
+
payload["errors"] = [_repair_failure_guidance(exc)]
|
|
236
|
+
if len(audit_ids) == 1:
|
|
237
|
+
payload["auditId"] = next(iter(audit_ids))
|
|
238
|
+
return payload, 3
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _stats_has_acknowledgements(fingerprints) -> bool:
|
|
242
|
+
if not _cctally_core.DB_PATH.exists():
|
|
243
|
+
return False
|
|
244
|
+
try:
|
|
245
|
+
conn = sqlite3.connect(
|
|
246
|
+
f"file:{_cctally_core.DB_PATH}?mode=ro", uri=True
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
rows = conn.execute(
|
|
250
|
+
"SELECT violation_json FROM journal_protocol_violations"
|
|
251
|
+
)
|
|
252
|
+
stored = {
|
|
253
|
+
item.get("fingerprint")
|
|
254
|
+
for item in (json.loads(str(row[0])) for row in rows)
|
|
255
|
+
if item.get("auditId")
|
|
256
|
+
}
|
|
257
|
+
return set(fingerprints) <= stored
|
|
258
|
+
finally:
|
|
259
|
+
conn.close()
|
|
260
|
+
except (OSError, sqlite3.Error, ValueError, TypeError):
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _audit_high_water(audit_ids, high_water):
|
|
265
|
+
found = {}
|
|
266
|
+
for segment, offset, raw in _journal._read_range(None, high_water):
|
|
267
|
+
record = _lib_journal.decode_line(raw)
|
|
268
|
+
if record is not None and record.get("id") in audit_ids:
|
|
269
|
+
found[record["id"]] = (segment, offset + len(raw) + 1)
|
|
270
|
+
if set(found) != set(audit_ids):
|
|
271
|
+
raise _journal.JournalError(
|
|
272
|
+
"acknowledged protocol audit record is missing from the journal"
|
|
273
|
+
)
|
|
274
|
+
return max(found.values(), key=lambda item: _lib_journal.segment_sort_key(
|
|
275
|
+
item[0]
|
|
276
|
+
) + (item[1],))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _apply(requested, initial_preview):
|
|
280
|
+
_call_pause_hook("before-lock")
|
|
281
|
+
with _repair_locks():
|
|
282
|
+
preview, selection = _preview_payload(requested)
|
|
283
|
+
if preview["errors"]:
|
|
284
|
+
preview["status"] = "conflict"
|
|
285
|
+
return preview, 2
|
|
286
|
+
if (
|
|
287
|
+
preview["journalHighWater"] != initial_preview["journalHighWater"]
|
|
288
|
+
or preview["journalPrefixHash"]
|
|
289
|
+
!= initial_preview["journalPrefixHash"]
|
|
290
|
+
or preview["selectedViolations"]
|
|
291
|
+
!= initial_preview["selectedViolations"]
|
|
292
|
+
):
|
|
293
|
+
preview["status"] = "conflict"
|
|
294
|
+
preview["errors"] = [
|
|
295
|
+
"journal changed before the repair lock was acquired; rerun "
|
|
296
|
+
"`cctally db journal-repair` for a fresh preview"
|
|
297
|
+
]
|
|
298
|
+
return preview, 2
|
|
299
|
+
by_fingerprint = {
|
|
300
|
+
violation.fingerprint: violation
|
|
301
|
+
for violation in selection.protocol_violations
|
|
302
|
+
}
|
|
303
|
+
to_acknowledge = [
|
|
304
|
+
by_fingerprint[fingerprint]
|
|
305
|
+
for fingerprint in requested
|
|
306
|
+
if fingerprint in by_fingerprint
|
|
307
|
+
]
|
|
308
|
+
reviewed_high_water = (
|
|
309
|
+
None
|
|
310
|
+
if preview["journalHighWater"] is None
|
|
311
|
+
else (
|
|
312
|
+
preview["journalHighWater"]["segment"],
|
|
313
|
+
preview["journalHighWater"]["offset"],
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
if to_acknowledge:
|
|
317
|
+
if reviewed_high_water is None:
|
|
318
|
+
raise _journal.JournalError(
|
|
319
|
+
"journal disappeared before protocol resolution append"
|
|
320
|
+
)
|
|
321
|
+
audit = _lib_journal.make_protocol_resolution(
|
|
322
|
+
at=_iso_now(),
|
|
323
|
+
violations=to_acknowledge,
|
|
324
|
+
journal_high_water=reviewed_high_water,
|
|
325
|
+
journal_prefix_hash=preview["journalPrefixHash"],
|
|
326
|
+
)
|
|
327
|
+
audit_high_water = _journal.append_records(
|
|
328
|
+
[audit],
|
|
329
|
+
expected_high_water=reviewed_high_water,
|
|
330
|
+
)
|
|
331
|
+
_call_crash_hook("after-audit-append")
|
|
332
|
+
try:
|
|
333
|
+
_call_rebuild_error_hook()
|
|
334
|
+
rebuild = _journal.rebuild_stats_index(
|
|
335
|
+
high_water=audit_high_water,
|
|
336
|
+
update_quota_cache=False,
|
|
337
|
+
before_swap=lambda: _call_crash_hook(
|
|
338
|
+
"before-rebuild-swap"
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
return _post_audit_failure(
|
|
343
|
+
requested,
|
|
344
|
+
{audit["id"]},
|
|
345
|
+
exc,
|
|
346
|
+
)
|
|
347
|
+
final_payload, _final_selection = _preview_payload(requested)
|
|
348
|
+
final_payload["status"] = "applied"
|
|
349
|
+
final_payload["selectedViolations"] = [
|
|
350
|
+
violation.to_dict() for violation in to_acknowledge
|
|
351
|
+
]
|
|
352
|
+
final_payload["auditId"] = audit["id"]
|
|
353
|
+
final_payload["reviewedJournalHighWater"] = (
|
|
354
|
+
_high_water_dict(reviewed_high_water)
|
|
355
|
+
)
|
|
356
|
+
final_payload["reviewedJournalPrefixHash"] = (
|
|
357
|
+
audit["payload"]["journal_prefix_hash"]
|
|
358
|
+
)
|
|
359
|
+
final_payload["rebuild"] = _rebuild_dict(rebuild)
|
|
360
|
+
return final_payload, 0
|
|
361
|
+
acknowledged = {
|
|
362
|
+
violation.fingerprint: violation
|
|
363
|
+
for violation in selection.acknowledged_protocol_violations
|
|
364
|
+
}
|
|
365
|
+
audit_ids = {
|
|
366
|
+
acknowledged[fingerprint].audit_id
|
|
367
|
+
for fingerprint in requested
|
|
368
|
+
if fingerprint in acknowledged
|
|
369
|
+
}
|
|
370
|
+
preview["status"] = "already-resolved"
|
|
371
|
+
if len(audit_ids) == 1:
|
|
372
|
+
preview["auditId"] = next(iter(audit_ids))
|
|
373
|
+
if not _stats_has_acknowledgements(requested):
|
|
374
|
+
recovery_high_water = _audit_high_water(
|
|
375
|
+
audit_ids, reviewed_high_water
|
|
376
|
+
)
|
|
377
|
+
try:
|
|
378
|
+
_call_rebuild_error_hook()
|
|
379
|
+
rebuild = _journal.rebuild_stats_index(
|
|
380
|
+
high_water=recovery_high_water,
|
|
381
|
+
update_quota_cache=False,
|
|
382
|
+
before_swap=lambda: _call_crash_hook(
|
|
383
|
+
"before-rebuild-swap"
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
return _post_audit_failure(
|
|
388
|
+
requested,
|
|
389
|
+
audit_ids,
|
|
390
|
+
exc,
|
|
391
|
+
)
|
|
392
|
+
final_payload, _final_selection = _preview_payload(requested)
|
|
393
|
+
final_payload["status"] = "recovered"
|
|
394
|
+
if len(audit_ids) == 1:
|
|
395
|
+
final_payload["auditId"] = next(iter(audit_ids))
|
|
396
|
+
final_payload["rebuild"] = _rebuild_dict(rebuild)
|
|
397
|
+
return final_payload, 0
|
|
398
|
+
return preview, 0
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cmd_db_journal_repair(args) -> int:
|
|
402
|
+
"""Preview structural violations without mutating the journal or indexes."""
|
|
403
|
+
try:
|
|
404
|
+
requested = list(getattr(args, "violation", ()) or ())
|
|
405
|
+
payload, _selection = _preview_payload(requested)
|
|
406
|
+
except (OSError, _lib_journal.JournalProtocolError) as exc:
|
|
407
|
+
if bool(getattr(args, "json", False)):
|
|
408
|
+
try:
|
|
409
|
+
high_water = _read_only_high_water()
|
|
410
|
+
except OSError:
|
|
411
|
+
high_water = None
|
|
412
|
+
payload = stamp_schema_version(
|
|
413
|
+
{
|
|
414
|
+
"status": "failed",
|
|
415
|
+
"journalHighWater": _high_water_dict(high_water),
|
|
416
|
+
"journalPrefixHash": None,
|
|
417
|
+
"unacknowledgedViolations": None,
|
|
418
|
+
"acknowledgedViolations": None,
|
|
419
|
+
"selectedViolations": [],
|
|
420
|
+
"auditId": None,
|
|
421
|
+
"rebuild": None,
|
|
422
|
+
"errors": [str(exc)],
|
|
423
|
+
},
|
|
424
|
+
version=1,
|
|
425
|
+
)
|
|
426
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
427
|
+
else:
|
|
428
|
+
print(f"cctally: db journal-repair failed: {exc}", file=sys.stderr)
|
|
429
|
+
return 2
|
|
430
|
+
if bool(getattr(args, "yes", False)) and not list(
|
|
431
|
+
getattr(args, "violation", ()) or ()
|
|
432
|
+
):
|
|
433
|
+
payload["status"] = "conflict"
|
|
434
|
+
payload["errors"] = [
|
|
435
|
+
"--yes requires at least one explicit --violation fingerprint"
|
|
436
|
+
]
|
|
437
|
+
if bool(getattr(args, "json", False)):
|
|
438
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
439
|
+
else:
|
|
440
|
+
print(
|
|
441
|
+
"cctally: db journal-repair: " + payload["errors"][0],
|
|
442
|
+
file=sys.stderr,
|
|
443
|
+
)
|
|
444
|
+
return 2
|
|
445
|
+
if payload["errors"]:
|
|
446
|
+
payload["status"] = "conflict"
|
|
447
|
+
if bool(getattr(args, "json", False)):
|
|
448
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
449
|
+
else:
|
|
450
|
+
for error in payload["errors"]:
|
|
451
|
+
print(f"cctally: db journal-repair: {error}", file=sys.stderr)
|
|
452
|
+
return 2
|
|
453
|
+
if bool(getattr(args, "yes", False)):
|
|
454
|
+
import _cctally_db
|
|
455
|
+
|
|
456
|
+
if _cctally_db._would_block_prod_stats(_cctally_core.DB_PATH):
|
|
457
|
+
message = (
|
|
458
|
+
"refusing to repair the prod stats.db "
|
|
459
|
+
"(~/.local/share/cctally) from a dev checkout; run the "
|
|
460
|
+
"installed binary or set CCTALLY_ALLOW_PROD_MIGRATION=1"
|
|
461
|
+
)
|
|
462
|
+
payload["status"] = "conflict"
|
|
463
|
+
payload["errors"] = [message]
|
|
464
|
+
if bool(getattr(args, "json", False)):
|
|
465
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
466
|
+
else:
|
|
467
|
+
print(f"cctally: db journal-repair: {message}", file=sys.stderr)
|
|
468
|
+
return 2
|
|
469
|
+
try:
|
|
470
|
+
payload, returncode = _apply(requested, payload)
|
|
471
|
+
except (
|
|
472
|
+
OSError,
|
|
473
|
+
_journal.JournalError,
|
|
474
|
+
_lib_journal.JournalProtocolError,
|
|
475
|
+
) as exc:
|
|
476
|
+
payload["status"] = "failed"
|
|
477
|
+
detail = _repair_failure_guidance(exc)
|
|
478
|
+
payload["errors"] = [detail]
|
|
479
|
+
if bool(getattr(args, "json", False)):
|
|
480
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
481
|
+
else:
|
|
482
|
+
print(
|
|
483
|
+
f"cctally: db journal-repair failed: {detail}",
|
|
484
|
+
file=sys.stderr,
|
|
485
|
+
)
|
|
486
|
+
return 3
|
|
487
|
+
if returncode != 0:
|
|
488
|
+
if bool(getattr(args, "json", False)):
|
|
489
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
490
|
+
else:
|
|
491
|
+
for error in payload["errors"]:
|
|
492
|
+
print(
|
|
493
|
+
f"cctally: db journal-repair: {error}",
|
|
494
|
+
file=sys.stderr,
|
|
495
|
+
)
|
|
496
|
+
return returncode
|
|
497
|
+
print(json.dumps(payload, separators=(",", ":"))) if bool(
|
|
498
|
+
getattr(args, "json", False)
|
|
499
|
+
) else print(
|
|
500
|
+
f"cctally: journal repair {payload['status']} — "
|
|
501
|
+
f"audit {payload['auditId'] or 'already recorded'}."
|
|
502
|
+
)
|
|
503
|
+
return returncode
|
|
504
|
+
if bool(getattr(args, "json", False)):
|
|
505
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
506
|
+
else:
|
|
507
|
+
violations = payload["unacknowledgedViolations"]
|
|
508
|
+
print(
|
|
509
|
+
f"cctally: journal repair preview — {len(violations)} "
|
|
510
|
+
"unacknowledged structural violation(s); no changes written."
|
|
511
|
+
)
|
|
512
|
+
for violation in violations[:10]:
|
|
513
|
+
print(
|
|
514
|
+
f" {violation['batchId']}: {violation['kind']} "
|
|
515
|
+
f"({violation['fingerprint']})"
|
|
516
|
+
)
|
|
517
|
+
if len(violations) > 10:
|
|
518
|
+
print(f" ... and {len(violations) - 10} more (see --json for all)")
|
|
519
|
+
return 0
|