cctally 1.92.2 → 1.93.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 (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
@@ -10,13 +10,31 @@ import pathlib
10
10
  import signal
11
11
  import sqlite3
12
12
  import sys
13
+ import typing
13
14
 
14
15
  import _cctally_core
15
16
  import _cctally_journal as _journal
17
+ import _lib_accounts
16
18
  import _lib_journal
19
+ import _lib_journal_router
17
20
  from _lib_json_envelope import stamp_schema_version
18
21
 
19
22
 
23
+ class _PrefixSnapshot(typing.NamedTuple):
24
+ """One pinned prefix, read exactly once (#496 S5 §4).
25
+
26
+ `audit_ends` maps each `journal_protocol_resolution` op id to the end
27
+ coordinate of its line. The already-resolved recovery branch needs those
28
+ coordinates and used to re-read the whole prefix through `_audit_high_water`
29
+ to find them, even though this pass had them in hand.
30
+ """
31
+
32
+ high_water: "tuple[str, int] | None"
33
+ prefix_hash: "str | None"
34
+ selection: object
35
+ audit_ends: dict
36
+
37
+
20
38
  def _read_only_high_water() -> "tuple[str, int] | None":
21
39
  """Capture the append-only prefix without creating a lock or sidecar."""
22
40
  segments = _journal.list_segments()
@@ -27,37 +45,99 @@ def _read_only_high_water() -> "tuple[str, int] | None":
27
45
 
28
46
 
29
47
  def _read_prefix(high_water):
48
+ """Stream the pinned prefix once, producing everything derived from it.
49
+
50
+ Returns `(records, evidence, prefix_hash, audit_ends)`. The prefix hash
51
+ comes from a `PrefixHashAccumulator` fed by the same bytes this pass reads,
52
+ the protocol evidence is captured from that accumulator rather than by
53
+ re-reading the prefix per resolution op, and the cutover account is captured
54
+ inline exactly as `rebuild_stats_index` captures it. Before this, those were
55
+ four separate whole-prefix traversals on top of this one (#496 S5 §4).
56
+
57
+ Every record stays decoded. Unlike the rebuild, the selector here feeds an
58
+ acknowledgement the repair command may then mint, and unlike the rebuild's
59
+ filtered retention there is no placeholder scheme to keep the `enumerate`
60
+ numbering identical — so the list is unfiltered, exactly as before.
61
+ """
30
62
  if high_water is None:
31
- return [], ()
63
+ return [], (), None, {}
64
+ segments = _journal.list_segments()
65
+ if high_water[0] not in segments:
66
+ raise OSError(
67
+ f"journal high-water segment is unavailable: {high_water[0]}"
68
+ )
32
69
  records = []
33
70
  evidence = []
71
+ audit_ends: dict = {}
34
72
  malformed = 0
35
73
  prior_high_water = None
36
- for segment, offset, raw in _journal._read_range(None, high_water):
74
+ cutover_captured = _journal._CUTOVER_UNSEEN
75
+ hasher = _lib_journal_router.PrefixHashAccumulator()
76
+ for segment, offset, raw in _journal._iter_range_with_segments(
77
+ None,
78
+ high_water,
79
+ segments,
80
+ on_segment=lambda name: hasher.begin_segment(name, prior_high_water),
81
+ on_bytes=hasher.extend,
82
+ ):
37
83
  record = _lib_journal.decode_line(raw)
38
84
  if record is None:
39
85
  malformed += 1
40
86
  prior_high_water = (segment, offset + len(raw) + 1)
41
87
  continue
42
- _journal._capture_protocol_prefix_evidence(
43
- record,
44
- prior_high_water,
45
- evidence,
46
- )
88
+ record_end = (segment, offset + len(raw) + 1)
89
+ if record.get("t") == "op":
90
+ _journal._capture_protocol_prefix_evidence(
91
+ record,
92
+ prior_high_water,
93
+ evidence,
94
+ hasher=hasher,
95
+ )
96
+ payload = record.get("payload")
97
+ if (
98
+ isinstance(payload, dict)
99
+ and payload.get("kind")
100
+ == _lib_journal._PROTOCOL_RESOLUTION_KIND
101
+ ):
102
+ audit_ends[record.get("id")] = record_end
103
+ # First cutover op wins, exactly as `find_accounts_cutover_op` scans.
104
+ if (
105
+ cutover_captured is _journal._CUTOVER_UNSEEN
106
+ and record.get("id") == _journal.CUTOVER_OP_ID
107
+ ):
108
+ cutover_captured = _journal._cutover_value_of(record)
47
109
  records.append(record)
48
- prior_high_water = (segment, offset + len(raw) + 1)
110
+ prior_high_water = record_end
111
+ prefix_hash = hasher.digest_at(high_water)
112
+ # The accumulator buffers the segment it is reading — 410 MB on the
113
+ # maintainer's journal — so it is dropped the moment its pass ends, before
114
+ # the normalization loop below, exactly as `rebuild_stats_index` drops it.
115
+ # It is dropped before the raise too, so a malformed prefix does not pin the
116
+ # buffer on the traceback while the exception unwinds.
117
+ hasher = None
49
118
  if malformed:
50
119
  raise _lib_journal.JournalProtocolError(
51
120
  f"journal prefix contains {malformed} malformed line(s)"
52
121
  )
53
- cutover_claude = _journal.resolve_cutover_claude_account()
122
+ # No suffix fallback, deliberately. `_read_only_high_water` pins the
123
+ # canonically-last segment at its full size, so this prefix IS the whole
124
+ # journal and an op the prefix does not contain is not in the journal at
125
+ # all — which is exactly what `resolve_cutover_claude_account` used to
126
+ # answer by re-reading every segment. An op appended in the window between
127
+ # that pin and this loop is therefore NOT seen, where the whole-journal scan
128
+ # would have found it; that divergence is accepted, because the account then
129
+ # matches the prefix the fingerprints are computed over and `_apply`'s
130
+ # conflict check catches a preview that has gone stale. The rebuild's
131
+ # `_resolve_cutover_for_rebuild` cannot be reused here: its fallback calls
132
+ # `journal_high_water`, which takes the leaf lock and so CREATES
133
+ # `journal.lock`, and the preview must leave no sidecar behind.
134
+ if cutover_captured is _journal._CUTOVER_UNSEEN or cutover_captured is None:
135
+ cutover_claude = _lib_accounts.UNATTRIBUTED
136
+ else:
137
+ cutover_claude = cutover_captured
54
138
  for record in records:
55
139
  _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)
140
+ return records, tuple(evidence), prefix_hash, audit_ends
61
141
 
62
142
 
63
143
  def _high_water_dict(high_water):
@@ -66,18 +146,21 @@ def _high_water_dict(high_water):
66
146
  return {"segment": high_water[0], "offset": high_water[1]}
67
147
 
68
148
 
69
- def _selection_snapshot():
149
+ def _selection_snapshot() -> _PrefixSnapshot:
70
150
  high_water = _read_only_high_water()
71
- records, evidence = _read_prefix(high_water)
151
+ records, evidence, prefix_hash, audit_ends = _read_prefix(high_water)
72
152
  selection = _lib_journal.resolve_effective_events(
73
153
  records,
74
154
  protocol_prefix_evidence=evidence,
75
155
  )
76
- return high_water, _prefix_hash(high_water), selection
156
+ return _PrefixSnapshot(high_water, prefix_hash, selection, audit_ends)
77
157
 
78
158
 
79
159
  def _preview_payload(requested=()):
80
- high_water, prefix_hash, selection = _selection_snapshot()
160
+ snapshot = _selection_snapshot()
161
+ high_water = snapshot.high_water
162
+ prefix_hash = snapshot.prefix_hash
163
+ selection = snapshot.selection
81
164
  unacknowledged = {
82
165
  violation.fingerprint: violation
83
166
  for violation in selection.protocol_violations
@@ -115,7 +198,7 @@ def _preview_payload(requested=()):
115
198
  "rebuild": None,
116
199
  "errors": errors,
117
200
  }
118
- return stamp_schema_version(body, version=1), selection
201
+ return stamp_schema_version(body, version=1), snapshot
119
202
 
120
203
 
121
204
  def _rebuild_dict(result):
@@ -230,7 +313,7 @@ def _repair_failure_guidance(exc: Exception) -> str:
230
313
 
231
314
  def _post_audit_failure(requested, audit_ids, exc):
232
315
  """Report durable acknowledgement truth when index publication declined."""
233
- payload, _selection = _preview_payload(requested)
316
+ payload, _snapshot = _preview_payload(requested)
234
317
  payload["status"] = "failed"
235
318
  payload["errors"] = [_repair_failure_guidance(exc)]
236
319
  if len(audit_ids) == 1:
@@ -261,12 +344,19 @@ def _stats_has_acknowledgements(fingerprints) -> bool:
261
344
  return False
262
345
 
263
346
 
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)
347
+ def _recovery_high_water(audit_ids, audit_ends):
348
+ """The last acknowledged audit record's end coordinate.
349
+
350
+ `audit_ends` is produced by the same streaming pass that produced the
351
+ selection, so an already-resolved recovery no longer re-reads the whole
352
+ prefix to find coordinates that pass already held (#496 S5 §4.1). The
353
+ ordering rule is the pre-change one: canonical segment order, then offset.
354
+ """
355
+ found = {
356
+ audit_id: audit_ends[audit_id]
357
+ for audit_id in audit_ids
358
+ if audit_id in audit_ends
359
+ }
270
360
  if set(found) != set(audit_ids):
271
361
  raise _journal.JournalError(
272
362
  "acknowledged protocol audit record is missing from the journal"
@@ -279,7 +369,8 @@ def _audit_high_water(audit_ids, high_water):
279
369
  def _apply(requested, initial_preview):
280
370
  _call_pause_hook("before-lock")
281
371
  with _repair_locks():
282
- preview, selection = _preview_payload(requested)
372
+ preview, snapshot = _preview_payload(requested)
373
+ selection = snapshot.selection
283
374
  if preview["errors"]:
284
375
  preview["status"] = "conflict"
285
376
  return preview, 2
@@ -347,7 +438,7 @@ def _apply(requested, initial_preview):
347
438
  {audit["id"]},
348
439
  exc,
349
440
  )
350
- final_payload, _final_selection = _preview_payload(requested)
441
+ final_payload, _final_snapshot = _preview_payload(requested)
351
442
  final_payload["status"] = "applied"
352
443
  final_payload["selectedViolations"] = [
353
444
  violation.to_dict() for violation in to_acknowledge
@@ -374,8 +465,8 @@ def _apply(requested, initial_preview):
374
465
  if len(audit_ids) == 1:
375
466
  preview["auditId"] = next(iter(audit_ids))
376
467
  if not _stats_has_acknowledgements(requested):
377
- recovery_high_water = _audit_high_water(
378
- audit_ids, reviewed_high_water
468
+ recovery_high_water = _recovery_high_water(
469
+ audit_ids, snapshot.audit_ends
379
470
  )
380
471
  try:
381
472
  _call_rebuild_error_hook()
@@ -395,7 +486,7 @@ def _apply(requested, initial_preview):
395
486
  audit_ids,
396
487
  exc,
397
488
  )
398
- final_payload, _final_selection = _preview_payload(requested)
489
+ final_payload, _final_snapshot = _preview_payload(requested)
399
490
  final_payload["status"] = "recovered"
400
491
  if len(audit_ids) == 1:
401
492
  final_payload["auditId"] = next(iter(audit_ids))
@@ -408,7 +499,7 @@ def cmd_db_journal_repair(args) -> int:
408
499
  """Preview structural violations without mutating the journal or indexes."""
409
500
  try:
410
501
  requested = list(getattr(args, "violation", ()) or ())
411
- payload, _selection = _preview_payload(requested)
502
+ payload, _snapshot = _preview_payload(requested)
412
503
  except (OSError, _lib_journal.JournalProtocolError) as exc:
413
504
  if bool(getattr(args, "json", False)):
414
505
  try:
@@ -30,7 +30,7 @@ import sys
30
30
  from dataclasses import replace
31
31
 
32
32
  from _cctally_core import make_week_ref, parse_iso_datetime
33
- from _cctally_quota import codex_quota_breakdown
33
+ from _cctally_quota import assert_projection_readable, codex_quota_breakdown
34
34
  from _lib_accounts import UNATTRIBUTED
35
35
  from _lib_codex_pools import is_model_scoped_codex_quota
36
36
  from _lib_dashboard_sources import dashboard_resource_key
@@ -677,6 +677,8 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
677
677
  # `quota_window_blocks.account_key` is NOT NULL DEFAULT 'unattributed'.
678
678
  account_clause = "" if account_key is None else "AND account_key=? "
679
679
  account_params = () if account_key is None else (account_key,)
680
+ # BEFORE the SQL, per #496 S5b section 4.7.
681
+ assert_projection_readable(stats_conn)
680
682
  rows = stats_conn.execute(
681
683
  "SELECT source_root_key, logical_limit_key, observed_slot, "
682
684
  " window_minutes, limit_id, limit_name, account_key, "
@@ -808,6 +810,7 @@ def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
808
810
  never-combine rule.
809
811
  """
810
812
  orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
813
+ assert_projection_readable(stats_conn)
811
814
  return stats_conn.execute(
812
815
  "SELECT source_root_key, logical_limit_key, observed_slot, "
813
816
  " window_minutes, limit_id, limit_name, account_key, "
@@ -768,12 +768,14 @@ def cmd_project(args: argparse.Namespace) -> int:
768
768
  # Shareable-reports gate: --format short-circuits the JSON / table
769
769
  # dispatch via `_share_render_and_emit`. The mutex in
770
770
  # `_add_share_args` keeps `--format` and `--json` from coexisting.
771
- # Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
772
- # before rendering, so default output anonymizes project labels to
773
- # `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
774
- # The builder populates `ProjectCell.label` / `ChartPoint.project_label`
775
- # / `ChartPoint.x_label` with REAL names; the wrapper-level scrubber is
776
- # the single chokepoint that rewrites them.
771
+ # Privacy invariant (Section 8.4 / 5.3): `_lib_share.render()` prepares
772
+ # the RAW snapshot the wrapper hands it, so default output anonymizes
773
+ # project labels to `project-1` / `project-2` / ...; `--reveal-projects`
774
+ # opts back in. The builder populates `ProjectCell.label` /
775
+ # `ChartPoint.project_label` / `ChartPoint.x_label` with REAL names;
776
+ # `render()` is the chokepoint that rewrites them. (It is NOT `_scrub()`:
777
+ # that function is retained for backward compatibility and no production
778
+ # path calls it.)
777
779
  if getattr(args, "format", None):
778
780
  # Note: --breakdown is a no-op under --format (snapshot focuses on
779
781
  # the headline per-project usage table + HBar chart; per-model