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,1052 @@
1
+ """Scratch rederivation adapter for #372 Task B.
2
+
3
+ This eager sibling replays retained Claude observations and relevant operator
4
+ records through the current ingest hooks on a disposable stats index. Derived
5
+ events are captured in memory; the durable journal, source cache, config,
6
+ projection files, provider state, and alert dispatcher are never mutated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import copy
13
+ import datetime as dt
14
+ import fcntl
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import shutil
19
+ import signal
20
+ import sqlite3
21
+ import sys
22
+ import tempfile
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import _cctally_core
27
+ import _cctally_journal as _journal
28
+ import _cctally_record as _record
29
+ import _lib_journal
30
+ import _lib_json_envelope
31
+ import _lib_rederive
32
+
33
+
34
+ class RederiveBusy(RuntimeError):
35
+ """The stable-view/apply lock set could not be acquired in time."""
36
+
37
+
38
+ class RederiveApplyError(RuntimeError):
39
+ """An operational apply stage failed after a concrete preview existed."""
40
+
41
+ def __init__(self, stage, preview, batch_id, cause):
42
+ super().__init__(f"{stage} failed: {cause}")
43
+ self.stage = stage
44
+ self.preview = preview
45
+ self.batch_id = batch_id
46
+ self.cause = cause
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RederivePreview:
51
+ plan: _lib_rederive.RederivePlan
52
+ records: tuple[dict, ...]
53
+ journal_high_water: "tuple[str, int] | None"
54
+ record_ends: tuple[tuple[str, int], ...]
55
+ generated_at: str
56
+ batch_id: "str | None"
57
+ incomplete_batch: bool
58
+ latest_completed_batch: "str | None"
59
+ latest_completed_high_water: "tuple[str, int] | None"
60
+ recovery_required: bool
61
+ # #374: the quarantined same-revision groups this plan will resolve by
62
+ # forcing a revision advance. Additive; empty on a clean journal.
63
+ journal_conflicts: tuple = ()
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class RederiveCommandResult:
68
+ preview: RederivePreview
69
+ status: str
70
+ batch_id: "str | None"
71
+ rebuild: "object | None"
72
+
73
+
74
+ _REDERIVE_LOCK_TIMEOUT_SECONDS = 5.0
75
+ _REDERIVE_CRASH_HOOK = None
76
+
77
+
78
+ def _canonical_bytes(value) -> bytes:
79
+ return json.dumps(
80
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
81
+ ).encode("utf-8")
82
+
83
+
84
+ def _fingerprint(value) -> str:
85
+ return "sha256:" + hashlib.sha256(_canonical_bytes(value)).hexdigest()
86
+
87
+
88
+ def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
89
+ names = {
90
+ row[0] for row in cache_conn.execute(
91
+ "SELECT name FROM sqlite_master WHERE type='table'")
92
+ }
93
+ return {
94
+ name: {
95
+ row[1] for row in cache_conn.execute(f"PRAGMA table_info({name})")
96
+ }
97
+ for name in names
98
+ }
99
+
100
+
101
+ def _cache_fingerprint(cache_conn: sqlite3.Connection) -> str:
102
+ """Hash every cost-bearing Claude cache row and its project metadata."""
103
+ entry_columns = (
104
+ "source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
105
+ "output_tokens", "cache_create_tokens", "cache_read_tokens",
106
+ "cache_create_1h_tokens", "cost_usd_raw", "speed", "account_key",
107
+ )
108
+ file_columns = ("path", "session_id", "project_path")
109
+ entries = [
110
+ list(row) for row in cache_conn.execute(
111
+ "SELECT " + ",".join(entry_columns)
112
+ + " FROM session_entries ORDER BY source_path, line_offset")
113
+ ]
114
+ files = [
115
+ list(row) for row in cache_conn.execute(
116
+ "SELECT " + ",".join(file_columns)
117
+ + " FROM session_files ORDER BY path")
118
+ ]
119
+ return _fingerprint({"sessionEntries": entries, "sessionFiles": files})
120
+
121
+
122
+ def _validate_cache_rows(cache_conn: sqlite3.Connection,
123
+ raw_records: list[dict]) -> None:
124
+ missing_metadata = cache_conn.execute(
125
+ "SELECT COUNT(*) FROM session_entries se "
126
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
127
+ "WHERE sf.path IS NULL"
128
+ ).fetchone()[0]
129
+ if missing_metadata:
130
+ raise _lib_rederive.RederiveDataGap(
131
+ "cache.db session_files metadata missing for "
132
+ f"{missing_metadata} session_entries row(s)"
133
+ )
134
+ unknown_split = cache_conn.execute(
135
+ "SELECT COUNT(*) FROM session_entries "
136
+ "WHERE cache_create_tokens > 0 AND cache_create_1h_tokens IS NULL"
137
+ ).fetchone()[0]
138
+ if unknown_split:
139
+ raise _lib_rederive.RederiveDataGap(
140
+ "cache.db cache_create_1h_tokens missing for "
141
+ f"{unknown_split} cache-write row(s)"
142
+ )
143
+ import _lib_accounts
144
+ positive_accounts = {
145
+ record.get("account") or _lib_accounts.UNATTRIBUTED
146
+ for record in raw_records
147
+ if record.get("t") == "obs"
148
+ and record.get("provider") == "claude"
149
+ and float((record.get("payload") or {}).get("weekly_percent") or 0) > 0
150
+ }
151
+ for account_key in sorted(positive_accounts):
152
+ if account_key == _lib_accounts.UNATTRIBUTED:
153
+ row = cache_conn.execute(
154
+ "SELECT 1 FROM session_entries "
155
+ "WHERE account_key IS NULL OR account_key = ? LIMIT 1",
156
+ (_lib_accounts.UNATTRIBUTED,),
157
+ ).fetchone()
158
+ else:
159
+ row = cache_conn.execute(
160
+ "SELECT 1 FROM session_entries WHERE account_key = ? LIMIT 1",
161
+ (account_key,),
162
+ ).fetchone()
163
+ if row is None:
164
+ raise _lib_rederive.RederiveDataGap(
165
+ "cache.db has no Claude session_entries for positive usage "
166
+ f"account {account_key}"
167
+ )
168
+
169
+
170
+ def _joined_entries(cache_conn, range_start, range_end, *,
171
+ project=None, account_key=None):
172
+ cache = sys.modules["_cctally_cache"]
173
+ start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
174
+ end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
175
+ sql = (
176
+ "SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
177
+ "se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
178
+ "sf.session_id, sf.project_path, se.cost_usd_raw, se.speed, "
179
+ "se.cache_create_1h_tokens "
180
+ "FROM session_entries se "
181
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
182
+ "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
183
+ )
184
+ params = [start_iso, end_iso]
185
+ if project is not None:
186
+ escaped = (
187
+ project.replace("\\", r"\\").replace("%", r"\%").replace("_", r"\_")
188
+ )
189
+ sql += r" AND se.source_path LIKE ? ESCAPE '\'"
190
+ params.append(f"%/projects/{escaped}/%")
191
+ if account_key is not None:
192
+ import _lib_accounts
193
+ if account_key == _lib_accounts.UNATTRIBUTED:
194
+ sql += " AND (se.account_key IS NULL OR se.account_key = ?)"
195
+ params.append(_lib_accounts.UNATTRIBUTED)
196
+ else:
197
+ sql += " AND se.account_key = ?"
198
+ params.append(account_key)
199
+ sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
200
+ out = []
201
+ for row in cache_conn.execute(sql, params):
202
+ out.append(cache._JoinedClaudeEntry(
203
+ timestamp=dt.datetime.fromisoformat(row[0]),
204
+ model=row[1],
205
+ input_tokens=int(row[2] or 0),
206
+ output_tokens=int(row[3] or 0),
207
+ cache_creation_tokens=int(row[4] or 0),
208
+ cache_read_tokens=int(row[5] or 0),
209
+ source_path=row[6],
210
+ session_id=row[7],
211
+ project_path=row[8],
212
+ cost_usd=row[9],
213
+ usage_extra=({"speed": row[10]} if row[10] else None),
214
+ cache_1h_tokens=(None if row[11] is None else int(row[11])),
215
+ ))
216
+ return out
217
+
218
+
219
+ @contextlib.contextmanager
220
+ def _scratch_read_adapters(cache_conn: sqlite3.Connection):
221
+ """Temporarily route current cost/config readers to stable supplied inputs."""
222
+ cctally = sys.modules["cctally"]
223
+ cache = sys.modules["_cctally_cache"]
224
+ replacements = {
225
+ "get_entries": lambda start, end, *, project=None, skip_sync=False,
226
+ account_key=None: cache.iter_entries(
227
+ cache_conn, start, end, project=project, account_key=account_key),
228
+ "get_claude_session_entries": (
229
+ lambda start, end, *, project=None, skip_sync=False, account_key=None:
230
+ _joined_entries(
231
+ cache_conn, start, end, project=project, account_key=account_key)
232
+ ),
233
+ # No historical alert/budget config exists in the journal. The family
234
+ # registry classifies those latches as re-materialized projections.
235
+ "load_config": lambda *args, **kwargs: {},
236
+ }
237
+ prior = {name: getattr(cctally, name) for name in replacements}
238
+ try:
239
+ for name, value in replacements.items():
240
+ setattr(cctally, name, value)
241
+ yield
242
+ finally:
243
+ for name, value in prior.items():
244
+ setattr(cctally, name, value)
245
+
246
+
247
+ def _rederivable_raw_records(records: list[dict]) -> list[dict]:
248
+ out = []
249
+ for record in records:
250
+ if record.get("t") == "obs":
251
+ if (
252
+ record.get("provider") == "claude"
253
+ and record.get("src") in _record._CLAUDE_OBS_SRCS
254
+ ):
255
+ out.append(record)
256
+ continue
257
+ if record.get("t") != "op":
258
+ continue
259
+ kind = (record.get("payload") or {}).get("kind")
260
+ if kind in {"weekly_credit_floor", "account_observe",
261
+ "account_label", "accounts_cutover", "sync_week"}:
262
+ out.append(record)
263
+ return out
264
+
265
+
266
+ def _normalize_legacy_accounts(records: list[dict]) -> None:
267
+ import _lib_accounts
268
+
269
+ cutover_values = {
270
+ (record.get("payload") or {}).get("claude_legacy_account")
271
+ for record in records
272
+ if record.get("t") == "op"
273
+ and (record.get("payload") or {}).get("kind") == "accounts_cutover"
274
+ }
275
+ if None in cutover_values:
276
+ raise _lib_rederive.RederiveConflict(
277
+ "accounts_cutover is missing claude_legacy_account"
278
+ )
279
+ if len(cutover_values) > 1:
280
+ raise _lib_rederive.RederiveConflict(
281
+ "conflicting accounts_cutover Claude account values"
282
+ )
283
+ cutover_claude = (
284
+ next(iter(cutover_values))
285
+ if cutover_values
286
+ else _lib_accounts.UNATTRIBUTED
287
+ )
288
+ for record in records:
289
+ _journal._normalize_legacy_account_stamp(record, cutover_claude)
290
+
291
+
292
+ def _derive_desired_events(records: list[dict], cache_conn: sqlite3.Connection,
293
+ scratch_dir: Path) -> list[dict]:
294
+ # #386: this replays the whole ingest pipeline into a PRIVATE scratch index
295
+ # and is reached from `db rederive`'s PREVIEW, which takes no locks by
296
+ # design (its contract is zero persistent writes to the live family). The
297
+ # connection is authorizer-armed like every other `open_db` handle, so the
298
+ # scratch replay has to declare itself sanctioned — otherwise a write-free
299
+ # preview is denied for writing to its own temp file. The scope is entered
300
+ # around the replay only; nothing here touches DB_PATH.
301
+ import _cctally_store
302
+
303
+ scratch_path = scratch_dir / "stats.rederive.db"
304
+ with _cctally_store.stats_write_scope("rederive-derive"):
305
+ return _derive_desired_events_into(
306
+ records, cache_conn, str(scratch_path))
307
+
308
+
309
+ def _derive_desired_events_into(records, cache_conn, scratch_path) -> list[dict]:
310
+ conn = _cctally_core.open_db(_target_path=str(scratch_path))
311
+ events: list[dict] = []
312
+ projection_state: dict = {}
313
+ hooks = (
314
+ _journal._pipeline_op_fold,
315
+ _record._pipeline_claude_usage,
316
+ _record._pipeline_record_credit,
317
+ _record._pipeline_sync_week,
318
+ )
319
+ try:
320
+ with _scratch_read_adapters(cache_conn):
321
+ for record in _rederivable_raw_records(records):
322
+ ctx = _journal.IngestContext(
323
+ conn=conn,
324
+ batch=[record],
325
+ config={},
326
+ event_sink=events,
327
+ projection_writes=False,
328
+ projection_state=projection_state,
329
+ )
330
+ conn.execute("BEGIN IMMEDIATE")
331
+ try:
332
+ for hook in hooks:
333
+ hook(ctx, record)
334
+ _journal._harvest(ctx)
335
+ conn.commit()
336
+ except BaseException:
337
+ conn.rollback()
338
+ raise
339
+ return events
340
+ finally:
341
+ conn.close()
342
+
343
+
344
+ def plan_claude_usage(
345
+ records,
346
+ *,
347
+ cache_conn: sqlite3.Connection,
348
+ journal_high_water: "tuple[str, int] | None",
349
+ protocol_prefix_evidence=(),
350
+ ):
351
+ """Produce a deterministic Task-A-compatible plan without durable writes.
352
+
353
+ ``records`` must be the canonical journal prefix ending at
354
+ ``journal_high_water``. ``cache_conn`` must be a caller-held stable SQLite
355
+ read view. Task C owns lock/snapshot orchestration; Task B owns the pure
356
+ replay and comparison contract.
357
+ """
358
+ records = copy.deepcopy(list(records))
359
+ _normalize_legacy_accounts(records)
360
+ report = _lib_rederive.validate_family_registry(
361
+ evt_kinds=set(_journal._EVT_SPECS),
362
+ op_kinds=(
363
+ set(_journal.FOLD_APPLIERS)
364
+ | set(_journal._ACCOUNTS_MACHINERY_KINDS)
365
+ | {"sync_week"}
366
+ ),
367
+ )
368
+ if report.unclassified_evt_kinds or report.unclassified_op_kinds:
369
+ raise _lib_rederive.RederiveConflict(
370
+ "unclassified journal kind(s): evt="
371
+ + ",".join(report.unclassified_evt_kinds)
372
+ + " op=" + ",".join(report.unclassified_op_kinds)
373
+ )
374
+ _lib_rederive.validate_claude_cache_contract(_cache_contract(cache_conn))
375
+ raw_records = _rederivable_raw_records(records)
376
+ _validate_cache_rows(cache_conn, raw_records)
377
+ cache_fingerprint = _cache_fingerprint(cache_conn)
378
+ config_fingerprint = _fingerprint({
379
+ "historicalConfig": "not-retained",
380
+ "projectionPolicy": "retire-and-rematerialize",
381
+ })
382
+ selection = _lib_journal.resolve_effective_events(
383
+ records,
384
+ protocol_prefix_evidence=protocol_prefix_evidence,
385
+ )
386
+ tainted = [
387
+ *selection.protocol_violations,
388
+ *selection.acknowledged_protocol_violations,
389
+ ]
390
+ if tainted:
391
+ summary = ", ".join(
392
+ f"{violation.batch_id}:{violation.kind}"
393
+ for violation in tainted[:10]
394
+ )
395
+ raise _lib_rederive.RederiveConflict(
396
+ "journal contains tainted correction batch(es): " + summary
397
+ )
398
+ with tempfile.TemporaryDirectory(prefix="cctally-rederive-") as tmp:
399
+ desired = _derive_desired_events(records, cache_conn, Path(tmp))
400
+ return _lib_rederive.build_claude_usage_plan(
401
+ selection=selection,
402
+ desired_events=desired,
403
+ journal_high_water=journal_high_water,
404
+ cache_fingerprint=cache_fingerprint,
405
+ config_fingerprint=config_fingerprint,
406
+ conflicted_event_ids=owned_conflicted_event_ids(selection),
407
+ )
408
+
409
+
410
+ def owned_conflicted_event_ids(selection) -> frozenset:
411
+ """The quarantined same-revision groups (#374) this family owns.
412
+
413
+ Scoped two ways: the selector already filters `conflicts` to the WINNING
414
+ revision (a group a completed rev-1 batch superseded is resolved, not
415
+ outstanding), and this filters to events `claude-usage` re-derives — a
416
+ conflict in a retained family (`quota_alert_arming`) or an unknown kind must
417
+ force no action, because a correction this family cannot re-derive would be
418
+ a fabrication."""
419
+ owned = set()
420
+ for conflict in getattr(selection, "conflicts", ()):
421
+ selected = selection.by_id.get(conflict.event_id)
422
+ if selected is None or selected.record is None:
423
+ continue
424
+ if _lib_rederive._is_owned_event(selected.record):
425
+ owned.add(conflict.event_id)
426
+ return frozenset(owned)
427
+
428
+
429
+ def read_rederive_journal_prefix(
430
+ high_water: "tuple[str, int] | None" = None,
431
+ ) -> tuple[list[dict], "tuple[str, int] | None", list[tuple[str, int]]]:
432
+ """Read and strictly decode one canonical journal prefix."""
433
+ if high_water is None:
434
+ high_water = _journal.journal_high_water()
435
+ if high_water is None:
436
+ return [], None, []
437
+ records: list[dict] = []
438
+ record_ends: list[tuple[str, int]] = []
439
+ malformed = 0
440
+ for segment, offset, raw in _journal._read_range(None, high_water):
441
+ record = _lib_journal.decode_line(raw)
442
+ if record is None:
443
+ malformed += 1
444
+ continue
445
+ records.append(record)
446
+ record_ends.append((segment, offset + len(raw) + 1))
447
+ if malformed:
448
+ raise _lib_rederive.RederiveConflict(
449
+ f"journal prefix contains {malformed} malformed line(s)"
450
+ )
451
+ return records, high_water, record_ends
452
+
453
+
454
+ def _protocol_prefix_evidence(records, record_ends):
455
+ evidence = []
456
+ prior_high_water = None
457
+ for record, record_end in zip(records, record_ends):
458
+ _journal._capture_protocol_prefix_evidence(
459
+ record,
460
+ prior_high_water,
461
+ evidence,
462
+ )
463
+ prior_high_water = record_end
464
+ return tuple(evidence)
465
+
466
+
467
+ def _read_only_journal_high_water() -> "tuple[str, int] | None":
468
+ """Capture an append-only prefix without creating a coordination file."""
469
+ segments = _journal.list_segments()
470
+ if not segments:
471
+ return None
472
+ latest = segments[-1]
473
+ return (latest, os.path.getsize(_cctally_core.JOURNAL_DIR / latest))
474
+
475
+
476
+ def _batch_id_for_plan(plan: _lib_rederive.RederivePlan) -> "str | None":
477
+ if not plan.actions:
478
+ return None
479
+ body = {
480
+ "family": plan.family,
481
+ "actions": [
482
+ action.to_correction_action() for action in plan.actions
483
+ ],
484
+ }
485
+ digest = hashlib.sha256(_canonical_bytes(body)).hexdigest()
486
+ return f"rederive:{plan.family}:{digest}"
487
+
488
+
489
+ def _iso_now() -> str:
490
+ return (
491
+ dt.datetime.now(dt.timezone.utc)
492
+ .replace(microsecond=0)
493
+ .isoformat()
494
+ .replace("+00:00", "Z")
495
+ )
496
+
497
+
498
+ @contextlib.contextmanager
499
+ def _open_sqlite_snapshot(path: Path, *, prefix: str):
500
+ """Yield a query-only SQLite family copy without touching the source."""
501
+ with tempfile.TemporaryDirectory(
502
+ prefix=prefix
503
+ ) as tmp:
504
+ snapshot = Path(tmp) / path.name
505
+ wal = path.with_name(path.name + "-wal")
506
+ # Copy the append-only WAL prefix before the main file. If a checkpoint
507
+ # races this read, a main-file identity change makes us retry rather
508
+ # than combining generations. SQLite validates the copied WAL frames.
509
+ for attempt in range(3):
510
+ before = path.stat()
511
+ snapshot_wal = snapshot.with_name(snapshot.name + "-wal")
512
+ snapshot_wal.unlink(missing_ok=True)
513
+ try:
514
+ wal_size = wal.stat().st_size
515
+ with wal.open("rb") as source, snapshot_wal.open("wb") as target:
516
+ shutil.copyfileobj(source, target, length=1024 * 1024)
517
+ target.truncate(wal_size)
518
+ except FileNotFoundError:
519
+ snapshot_wal.unlink(missing_ok=True)
520
+ shutil.copyfile(path, snapshot)
521
+ after = path.stat()
522
+ if (
523
+ before.st_ino,
524
+ before.st_size,
525
+ before.st_mtime_ns,
526
+ ) == (
527
+ after.st_ino,
528
+ after.st_size,
529
+ after.st_mtime_ns,
530
+ ):
531
+ break
532
+ if attempt == 2:
533
+ raise RederiveBusy(
534
+ "cache.db changed during the read-only preview snapshot; "
535
+ "retry shortly"
536
+ )
537
+ conn = sqlite3.connect(snapshot)
538
+ try:
539
+ conn.execute("PRAGMA query_only=ON")
540
+ conn.execute("BEGIN")
541
+ yield conn
542
+ finally:
543
+ conn.close()
544
+
545
+
546
+ @contextlib.contextmanager
547
+ def _open_cache_read_view():
548
+ """Yield a stable cache snapshot without touching source WAL sidecars."""
549
+ path = _cctally_core.CACHE_DB_PATH
550
+ if not path.exists():
551
+ raise _lib_rederive.RederiveDataGap(
552
+ f"missing cache.db at {path}"
553
+ )
554
+ with _open_sqlite_snapshot(
555
+ path,
556
+ prefix="cctally-rederive-cache-",
557
+ ) as conn:
558
+ yield conn
559
+
560
+
561
+ def _latest_completed_family_batch(
562
+ records: list[dict],
563
+ record_ends: list[tuple[str, int]],
564
+ completed: frozenset[str],
565
+ family: str,
566
+ ) -> tuple["str | None", "tuple[str, int] | None"]:
567
+ latest_id = None
568
+ latest_high_water = None
569
+ for record, record_end in zip(records, record_ends):
570
+ if (
571
+ record.get("t") == "correction_batch"
572
+ and record.get("phase") == "commit"
573
+ and record.get("family") == family
574
+ and record.get("id") in completed
575
+ ):
576
+ latest_id = record["id"]
577
+ latest_high_water = record_end
578
+ return latest_id, latest_high_water
579
+
580
+
581
+ def _preview_from_snapshot(
582
+ family: str,
583
+ *,
584
+ journal_high_water: "tuple[str, int] | None" = None,
585
+ ) -> RederivePreview:
586
+ if family != _lib_rederive.FAMILY:
587
+ raise _lib_rederive.RederiveConflict(
588
+ f"unsupported rederive family: {family}"
589
+ )
590
+ if journal_high_water is None:
591
+ journal_high_water = _read_only_journal_high_water()
592
+ if journal_high_water is None:
593
+ records, high_water, record_ends = [], None, []
594
+ else:
595
+ records, high_water, record_ends = read_rederive_journal_prefix(
596
+ journal_high_water
597
+ )
598
+ protocol_evidence = _protocol_prefix_evidence(records, record_ends)
599
+ with _open_cache_read_view() as cache:
600
+ plan = plan_claude_usage(
601
+ records,
602
+ cache_conn=cache,
603
+ journal_high_water=high_water,
604
+ protocol_prefix_evidence=protocol_evidence,
605
+ )
606
+
607
+ selection = _lib_journal.resolve_effective_events(
608
+ records,
609
+ protocol_prefix_evidence=protocol_evidence,
610
+ )
611
+ batch_id = _batch_id_for_plan(plan)
612
+ generated_at = _iso_now()
613
+ incomplete = False
614
+ if batch_id is not None:
615
+ begins = [
616
+ record for record in records
617
+ if record.get("t") == "correction_batch"
618
+ and record.get("phase") == "begin"
619
+ and record.get("id") == batch_id
620
+ ]
621
+ if begins:
622
+ generated_at = str(begins[0]["at"])
623
+ incomplete = batch_id not in selection.completed_batches
624
+ latest_id, latest_high_water = _latest_completed_family_batch(
625
+ records,
626
+ record_ends,
627
+ selection.completed_batches,
628
+ family,
629
+ )
630
+ recovery_required = (
631
+ latest_id is not None and not _stats_has_batch(latest_id)
632
+ )
633
+ owned_conflicts = owned_conflicted_event_ids(selection)
634
+ journal_conflicts = tuple(
635
+ conflict for conflict in selection.conflicts
636
+ if conflict.event_id in owned_conflicts
637
+ )
638
+ return RederivePreview(
639
+ journal_conflicts=journal_conflicts,
640
+ plan=plan,
641
+ records=tuple(records),
642
+ journal_high_water=high_water,
643
+ record_ends=tuple(record_ends),
644
+ generated_at=generated_at,
645
+ batch_id=batch_id,
646
+ incomplete_batch=incomplete,
647
+ latest_completed_batch=latest_id,
648
+ latest_completed_high_water=latest_high_water,
649
+ recovery_required=recovery_required,
650
+ )
651
+
652
+
653
+ @contextlib.contextmanager
654
+ def _rederive_locks(*, apply: bool, timeout: float):
655
+ """Acquire the stable-view locks in the repository's total order."""
656
+ from _lib_cache_writer_lock import (
657
+ acquire_ordered_flocks,
658
+ release_cache_writer_flocks,
659
+ )
660
+
661
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
662
+ locks = [
663
+ (
664
+ _cctally_core.STATS_LOCK_MAINTENANCE_PATH,
665
+ fcntl.LOCK_EX if apply else fcntl.LOCK_SH,
666
+ ),
667
+ (_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
668
+ ]
669
+ if apply:
670
+ locks.append((_cctally_core.JOURNAL_INGEST_LOCK_PATH, fcntl.LOCK_EX))
671
+ locks.append((_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX))
672
+ held = acquire_ordered_flocks(locks, timeout=timeout)
673
+ if held is None:
674
+ raise RederiveBusy(
675
+ "another database sync or maintenance operation holds the "
676
+ "rederive lock set; retry shortly"
677
+ )
678
+ # #386: record the stats maintenance hold so a nested live `open_db()` does
679
+ # not request SHARED on a second fd of this same file and self-deadlock, and
680
+ # (when applying) declare the sanctioned write regime + the ingest hold so a
681
+ # heal reached from in here recognises itself as the serialized writer.
682
+ import _cctally_store
683
+
684
+ _cctally_core.note_stats_maintenance_acquired()
685
+ try:
686
+ with _cctally_store.stats_write_scope("rederive", ingest_lock=apply):
687
+ yield
688
+ finally:
689
+ _cctally_core.note_stats_maintenance_released()
690
+ release_cache_writer_flocks(held)
691
+
692
+
693
+ def preview_db_rederive(
694
+ family: str,
695
+ *,
696
+ lock_timeout: float = _REDERIVE_LOCK_TIMEOUT_SECONDS,
697
+ ) -> RederivePreview:
698
+ # Preview has a literal zero-persistent-write contract. A fixed append-only
699
+ # journal prefix and a read-only SQLite transaction are stable inputs
700
+ # without creating any coordination files.
701
+ del lock_timeout
702
+ return _preview_from_snapshot(family)
703
+
704
+
705
+ def _stats_has_batch(batch_id: str) -> bool:
706
+ path = _cctally_core.DB_PATH
707
+ if not path.exists():
708
+ return False
709
+ try:
710
+ with _open_sqlite_snapshot(
711
+ path,
712
+ prefix="cctally-rederive-stats-",
713
+ ) as conn:
714
+ row = conn.execute(
715
+ "SELECT 1 FROM journal_effective_events "
716
+ "WHERE batch_id = ? LIMIT 1",
717
+ (batch_id,),
718
+ ).fetchone()
719
+ return row is not None
720
+ except (OSError, sqlite3.Error):
721
+ return False
722
+
723
+
724
+ def _call_crash_hook(stage: str) -> None:
725
+ if _REDERIVE_CRASH_HOOK is not None:
726
+ _REDERIVE_CRASH_HOOK(stage)
727
+ if (
728
+ os.environ.get("CCTALLY_REDERIVE_TEST_MODE") == "1"
729
+ and os.environ.get("CCTALLY_REDERIVE_TEST_CRASH_STAGE") == stage
730
+ ):
731
+ os.kill(os.getpid(), signal.SIGKILL)
732
+
733
+
734
+ def apply_db_rederive(
735
+ family: str,
736
+ *,
737
+ lock_timeout: float = _REDERIVE_LOCK_TIMEOUT_SECONDS,
738
+ ) -> RederiveCommandResult:
739
+ with _rederive_locks(apply=True, timeout=lock_timeout):
740
+ preview = _preview_from_snapshot(family)
741
+ plan = preview.plan
742
+ recovering_prior = (
743
+ preview.latest_completed_batch is not None
744
+ and not _stats_has_batch(preview.latest_completed_batch)
745
+ )
746
+ if plan.actions:
747
+ assert preview.batch_id is not None
748
+ batch = _lib_journal.make_correction_batch(
749
+ batch_id=preview.batch_id,
750
+ family=family,
751
+ at=preview.generated_at,
752
+ actions=plan.to_correction_actions(),
753
+ )
754
+ try:
755
+ batch_high_water = _journal.append_records(
756
+ batch,
757
+ expected_high_water=preview.journal_high_water,
758
+ line_hook=lambda index: _call_crash_hook(
759
+ f"after-batch-line-{index}"
760
+ ),
761
+ )
762
+ except Exception as exc:
763
+ raise RederiveApplyError(
764
+ "correction append", preview, preview.batch_id, exc
765
+ ) from exc
766
+ try:
767
+ _call_crash_hook("after-batch-commit")
768
+ result = _journal.rebuild_stats_index(
769
+ high_water=batch_high_water,
770
+ update_quota_cache=False,
771
+ before_swap=lambda: _call_crash_hook(
772
+ "before-rebuild-swap"
773
+ ),
774
+ )
775
+ _call_crash_hook("after-rebuild")
776
+ except Exception as exc:
777
+ raise RederiveApplyError(
778
+ "stats rebuild", preview, preview.batch_id, exc
779
+ ) from exc
780
+ status = (
781
+ "recovered"
782
+ if preview.incomplete_batch or recovering_prior
783
+ else "applied"
784
+ )
785
+ return RederiveCommandResult(
786
+ preview=preview,
787
+ status=status,
788
+ batch_id=preview.batch_id,
789
+ rebuild=result,
790
+ )
791
+
792
+ latest = preview.latest_completed_batch
793
+ if latest is not None and not _stats_has_batch(latest):
794
+ if preview.latest_completed_high_water is None:
795
+ raise RederiveBusy(
796
+ f"completed correction batch {latest} has no commit high-water"
797
+ )
798
+ try:
799
+ result = _journal.rebuild_stats_index(
800
+ high_water=preview.latest_completed_high_water,
801
+ update_quota_cache=False,
802
+ before_swap=lambda: _call_crash_hook(
803
+ "before-rebuild-swap"
804
+ ),
805
+ )
806
+ _call_crash_hook("after-rebuild")
807
+ except Exception as exc:
808
+ raise RederiveApplyError(
809
+ "stats recovery", preview, latest, exc
810
+ ) from exc
811
+ return RederiveCommandResult(
812
+ preview=preview,
813
+ status="recovered",
814
+ batch_id=latest,
815
+ rebuild=result,
816
+ )
817
+ return RederiveCommandResult(
818
+ preview=preview,
819
+ status="no-op",
820
+ batch_id=latest,
821
+ rebuild=None,
822
+ )
823
+
824
+
825
+ def _high_water_dict(high_water):
826
+ if high_water is None:
827
+ return None
828
+ return {"segment": high_water[0], "offset": high_water[1]}
829
+
830
+
831
+ def _rebuild_dict(result):
832
+ if result is None:
833
+ return None
834
+ return {
835
+ "segmentsRead": result.segments_read,
836
+ "linesFolded": result.lines_folded,
837
+ "malformed": result.malformed,
838
+ "durationSeconds": round(result.duration_s, 3),
839
+ "rowsByTable": result.rows_by_table,
840
+ }
841
+
842
+
843
+ def _command_payload(
844
+ *,
845
+ status: str,
846
+ preview: "RederivePreview | None" = None,
847
+ batch_id: "str | None" = None,
848
+ rebuild=None,
849
+ conflicts=(),
850
+ data_gaps=(),
851
+ errors=(),
852
+ family: str = _lib_rederive.FAMILY,
853
+ journal_high_water=None,
854
+ ):
855
+ plan = None if preview is None else preview.plan
856
+ counts = (
857
+ {"retain": 0, "supersede": 0, "tombstone": 0, "add": 0}
858
+ if plan is None else dict(plan.counts)
859
+ )
860
+ body = {
861
+ "status": status,
862
+ "family": family,
863
+ "journalHighWater": (
864
+ _high_water_dict(journal_high_water)
865
+ if preview is None
866
+ else _high_water_dict(preview.journal_high_water)
867
+ ),
868
+ "batchId": batch_id,
869
+ "planHash": None if plan is None else plan.plan_hash,
870
+ "actionCounts": counts,
871
+ # `conflicts` is the LEGACY key and keeps its meaning: command-validation
872
+ # failure messages (unsupported family, prod guard, structural journal
873
+ # protocol errors). #374's quarantined same-revision GROUPS ride the new
874
+ # `journalConflicts` key — never overload the old one.
875
+ "conflicts": list(conflicts),
876
+ "journalConflicts": [
877
+ conflict.to_dict()
878
+ for conflict in (() if preview is None else preview.journal_conflicts)
879
+ ],
880
+ "dataGaps": list(data_gaps),
881
+ "errors": list(errors),
882
+ "rebuild": _rebuild_dict(rebuild),
883
+ "noOp": status == "no-op",
884
+ }
885
+ return _lib_json_envelope.stamp_schema_version(body, version=1)
886
+
887
+
888
+ def _emit_command_payload(payload: dict, *, as_json: bool) -> None:
889
+ if as_json:
890
+ print(json.dumps(payload, separators=(",", ":")))
891
+ return
892
+ status = payload["status"]
893
+ if status == "preview":
894
+ actions = sum(
895
+ payload["actionCounts"][name]
896
+ for name in ("supersede", "tombstone", "add")
897
+ )
898
+ if actions == 0 and payload["batchId"] is not None:
899
+ print(
900
+ f"cctally: rederive preview for {payload['family']} — "
901
+ f"completed batch {payload['batchId']} needs stats.db recovery; "
902
+ "no changes written."
903
+ )
904
+ else:
905
+ print(
906
+ f"cctally: rederive preview for {payload['family']} — "
907
+ f"{actions} correction action(s); no changes written."
908
+ )
909
+ elif status == "applied":
910
+ print(
911
+ f"cctally: applied {payload['family']} correction batch "
912
+ f"{payload['batchId']} and rebuilt stats.db."
913
+ )
914
+ elif status == "recovered":
915
+ print(
916
+ f"cctally: recovered {payload['family']} correction batch "
917
+ f"{payload['batchId']} and rebuilt stats.db."
918
+ )
919
+ elif status == "no-op":
920
+ print(
921
+ f"cctally: {payload['family']} is already current; "
922
+ "no correction batch was appended."
923
+ )
924
+
925
+
926
+ def cmd_db_rederive(args) -> int:
927
+ """Preview or apply one audited Claude-usage correction plan."""
928
+ family = str(getattr(args, "family", ""))
929
+ as_json = bool(getattr(args, "json", False))
930
+ apply = bool(getattr(args, "yes", False))
931
+ try:
932
+ journal_high_water = _read_only_journal_high_water()
933
+ except OSError as exc:
934
+ payload = _command_payload(
935
+ status="failed",
936
+ family=family,
937
+ errors=(str(exc),),
938
+ )
939
+ if as_json:
940
+ _emit_command_payload(payload, as_json=True)
941
+ else:
942
+ print(f"cctally: db rederive failed: {exc}", file=sys.stderr)
943
+ return 3
944
+ if family != _lib_rederive.FAMILY:
945
+ message = f"unsupported rederive family: {family}"
946
+ payload = _command_payload(
947
+ status="conflict",
948
+ family=family,
949
+ journal_high_water=journal_high_water,
950
+ conflicts=(message,),
951
+ )
952
+ if as_json:
953
+ _emit_command_payload(payload, as_json=True)
954
+ else:
955
+ print(f"cctally: db rederive: {message}", file=sys.stderr)
956
+ return 2
957
+ if apply:
958
+ import _cctally_db
959
+
960
+ if _cctally_db._would_block_prod_stats(_cctally_core.DB_PATH):
961
+ message = (
962
+ "refusing to rederive the prod stats.db "
963
+ "(~/.local/share/cctally) from a dev checkout; run the "
964
+ "installed binary or set CCTALLY_ALLOW_PROD_MIGRATION=1"
965
+ )
966
+ payload = _command_payload(
967
+ status="conflict",
968
+ family=family,
969
+ journal_high_water=journal_high_water,
970
+ conflicts=(message,),
971
+ )
972
+ if as_json:
973
+ _emit_command_payload(payload, as_json=True)
974
+ else:
975
+ print(f"cctally: db rederive: {message}", file=sys.stderr)
976
+ return 2
977
+ try:
978
+ if apply:
979
+ result = apply_db_rederive(family)
980
+ payload = _command_payload(
981
+ status=result.status,
982
+ preview=result.preview,
983
+ batch_id=result.batch_id,
984
+ rebuild=result.rebuild,
985
+ )
986
+ else:
987
+ preview = preview_db_rederive(family)
988
+ status = (
989
+ "preview"
990
+ if preview.plan.actions or preview.recovery_required
991
+ else "no-op"
992
+ )
993
+ payload = _command_payload(
994
+ status=status,
995
+ preview=preview,
996
+ batch_id=(
997
+ preview.latest_completed_batch
998
+ if status == "no-op" or preview.recovery_required
999
+ else preview.batch_id
1000
+ ),
1001
+ )
1002
+ except _lib_rederive.RederiveDataGap as exc:
1003
+ payload = _command_payload(
1004
+ status="missing-source",
1005
+ family=family,
1006
+ journal_high_water=journal_high_water,
1007
+ data_gaps=(str(exc),),
1008
+ )
1009
+ if as_json:
1010
+ _emit_command_payload(payload, as_json=True)
1011
+ else:
1012
+ print(f"cctally: db rederive missing source: {exc}", file=sys.stderr)
1013
+ return 2
1014
+ except (_lib_rederive.RederiveConflict,
1015
+ _lib_journal.JournalProtocolError) as exc:
1016
+ payload = _command_payload(
1017
+ status="conflict",
1018
+ family=family,
1019
+ journal_high_water=journal_high_water,
1020
+ conflicts=(str(exc),),
1021
+ )
1022
+ if as_json:
1023
+ _emit_command_payload(payload, as_json=True)
1024
+ else:
1025
+ print(f"cctally: db rederive conflict: {exc}", file=sys.stderr)
1026
+ return 2
1027
+ except RederiveApplyError as exc:
1028
+ payload = _command_payload(
1029
+ status="failed",
1030
+ preview=exc.preview,
1031
+ batch_id=exc.batch_id,
1032
+ errors=(str(exc),),
1033
+ )
1034
+ if as_json:
1035
+ _emit_command_payload(payload, as_json=True)
1036
+ else:
1037
+ print(f"cctally: db rederive failed: {exc}", file=sys.stderr)
1038
+ return 3
1039
+ except (RederiveBusy, _journal.JournalError, sqlite3.Error, OSError) as exc:
1040
+ payload = _command_payload(
1041
+ status="failed",
1042
+ family=family,
1043
+ journal_high_water=journal_high_water,
1044
+ errors=(str(exc),),
1045
+ )
1046
+ if as_json:
1047
+ _emit_command_payload(payload, as_json=True)
1048
+ else:
1049
+ print(f"cctally: db rederive failed: {exc}", file=sys.stderr)
1050
+ return 3
1051
+ _emit_command_payload(payload, as_json=as_json)
1052
+ return 0