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.
Files changed (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. 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